searchlight_highlight 0.2.1 copy "searchlight_highlight: ^0.2.1" to clipboard
searchlight_highlight: ^0.2.1 copied to clipboard

A pure Dart reimplementation of Orama's standalone highlight package.

example/lib/main.dart

import 'package:file_selector/file_selector.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:searchlight_highlight/searchlight_highlight.dart';
import 'package:searchlight_highlight_example/src/folder_source_loader.dart';
import 'package:searchlight_highlight_example/src/loaded_validation_source.dart';
import 'package:searchlight_highlight_example/src/parsedoc_record.dart';
import 'package:searchlight_highlight_example/src/search_index_service.dart';
import 'package:searchlight_highlight_example/src/search_result_item.dart';

void main() {
  runApp(const HighlightValidationApp());
}

enum ValidationMode { standalone, parsedoc }

class HighlightValidationApp extends StatelessWidget {
  const HighlightValidationApp({
    super.key,
    this.folderSourceLoader,
    this.supportsDesktopFolderSource,
    this.pickDirectory,
  });

  final FolderSourceLoader? folderSourceLoader;
  final bool? supportsDesktopFolderSource;
  final Future<String?> Function()? pickDirectory;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0E6B52)),
        useMaterial3: true,
      ),
      home: HighlightValidationScreen(
        folderSourceLoader: folderSourceLoader ?? createFolderSourceLoader(),
        supportsDesktopFolderSource:
            supportsDesktopFolderSource ??
            _defaultSupportsDesktopFolderSource(),
        pickDirectory: pickDirectory ?? getDirectoryPath,
      ),
    );
  }

  bool _defaultSupportsDesktopFolderSource() {
    if (kIsWeb) {
      return false;
    }

    return switch (defaultTargetPlatform) {
      TargetPlatform.macOS ||
      TargetPlatform.windows ||
      TargetPlatform.linux => true,
      _ => false,
    };
  }
}

class HighlightValidationScreen extends StatefulWidget {
  const HighlightValidationScreen({
    required this.folderSourceLoader,
    required this.supportsDesktopFolderSource,
    required this.pickDirectory,
    super.key,
  });

  final FolderSourceLoader folderSourceLoader;
  final bool supportsDesktopFolderSource;
  final Future<String?> Function() pickDirectory;

  @override
  State<HighlightValidationScreen> createState() =>
      _HighlightValidationScreenState();
}

class _HighlightValidationScreenState extends State<HighlightValidationScreen> {
  static const _standaloneSeedText =
      'Alice was beginning to get very tired of sitting by her sister on '
      'the bank, and of having nothing to do: once or twice she had '
      'peeped into the book her sister was reading, but it had no '
      'pictures or conversations in it, "and what is the use of a book," '
      'thought Alice "without pictures or conversations?"\n\n'
      'So she was considering in her own mind (as well as she could, for '
      'the hot day made her feel very sleepy and stupid), whether the '
      'pleasure of making a daisy-chain would be worth the trouble of '
      'getting up and picking the daisies, when suddenly a White Rabbit '
      'with pink eyes ran close by her.\n\n'
      'There was nothing so very remarkable in that; nor did Alice think '
      'it so very much out of the way to hear the Rabbit say to itself, '
      '"Oh dear! Oh dear! I shall be late!" (when she thought it over '
      'afterwards, it occurred to her that she ought to have wondered at '
      'this, but at the time it all seemed quite natural); but when the '
      'Rabbit actually took a watch out of its waistcoat-pocket, and '
      'looked at it, and then hurried on, Alice started to her feet, for '
      'it flashed across her mind that she had never before seen a rabbit '
      'with either a waistcoat-pocket, or a watch to take out of it, and '
      'burning with curiosity, she ran across the field after it, and '
      'fortunately was just in time to see it pop down a large '
      'rabbit-hole under the hedge.\n\n'
      'In another moment down went Alice after it, never once considering '
      'how in the world she was to get out again.';
  static const _standaloneSeedQuery = 'Alice Rabbit';
  static const _exampleHighlightCssClass = 'searchlight-highlight';

  final TextEditingController _standaloneTextController = TextEditingController(
    text: _standaloneSeedText,
  );
  final TextEditingController _standaloneQueryController =
      TextEditingController(text: _standaloneSeedQuery);
  final TextEditingController _parsedocQueryController =
      TextEditingController();
  final SearchIndexService _searchIndexService = const SearchIndexService();

  ValidationMode _mode = ValidationMode.standalone;
  bool _caseSensitive = false;
  String _strategy = highlightStrategy.PARTIAL_MATCH;

  LoadedValidationSource? _source;
  List<SearchResultItem> _results = const [];
  ParsedocRecord? _selectedRecord;
  bool _loading = false;
  String? _error;

  @override
  void initState() {
    super.initState();
    _parsedocQueryController.addListener(_runParsedocSearch);
    _standaloneTextController.addListener(_refreshStandalone);
    _standaloneQueryController.addListener(_refreshStandalone);
  }

  @override
  void dispose() {
    _standaloneTextController
      ..removeListener(_refreshStandalone)
      ..dispose();
    _standaloneQueryController
      ..removeListener(_refreshStandalone)
      ..dispose();
    _parsedocQueryController
      ..removeListener(_runParsedocSearch)
      ..dispose();
    _source?.dispose();
    super.dispose();
  }

  void _refreshStandalone() {
    if (_mode == ValidationMode.standalone && mounted) {
      setState(() {});
    }
  }

  Future<void> _chooseFolder() async {
    if (!widget.supportsDesktopFolderSource) {
      _showMessage(
        'Desktop folder indexing is only available in desktop builds.',
      );
      return;
    }

    final path = await widget.pickDirectory();
    if (path == null || path.isEmpty) {
      return;
    }

    await _loadFolder(path);
  }

  Future<void> _loadFolder(String rootPath) async {
    setState(() {
      _loading = true;
      _error = null;
      _results = const [];
      _selectedRecord = null;
    });

    final previous = _source;
    _source = null;
    await previous?.dispose();

    try {
      final loadResult = await widget.folderSourceLoader.load(rootPath);
      final recordsById = {
        for (final record in loadResult.records) record.id: record,
      };
      final nextSource = LoadedValidationSource(
        db: loadResult.db,
        records: loadResult.records,
        recordsById: recordsById,
        label: loadResult.rootPath,
        discoveredCount: loadResult.discoveredSupportedFiles,
        issues: loadResult.issues,
      );
      if (!mounted) {
        await nextSource.dispose();
        return;
      }

      setState(() {
        _source = nextSource;
        _loading = false;
        _results = _searchIndexService.browseAll(nextSource);
        _selectedRecord = nextSource.records.isEmpty
            ? null
            : nextSource.records.first;
      });
    } on Object catch (error) {
      if (!mounted) {
        return;
      }

      setState(() {
        _loading = false;
        _error = error.toString();
      });
    }
  }

  void _runParsedocSearch() {
    final source = _source;
    if (source == null) {
      return;
    }

    setState(() {
      _results = _searchIndexService.search(
        source,
        _parsedocQueryController.text,
      );
      _selectedRecord = _results.isEmpty ? null : _results.first.record;
    });
  }

  void _showMessage(String message) {
    final messenger = ScaffoldMessenger.maybeOf(context);
    messenger?.showSnackBar(SnackBar(content: Text(message)));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Searchlight Highlight Validation')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Standalone highlight plus parsedoc-backed integration validation',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 16),
            SegmentedButton<ValidationMode>(
              segments: const [
                ButtonSegment<ValidationMode>(
                  value: ValidationMode.standalone,
                  label: Text('Standalone highlight'),
                  icon: Icon(Icons.text_fields),
                ),
                ButtonSegment<ValidationMode>(
                  value: ValidationMode.parsedoc,
                  label: Text('Parsedoc + highlight'),
                  icon: Icon(Icons.library_books),
                ),
              ],
              selected: {_mode},
              onSelectionChanged: (selection) {
                setState(() {
                  _mode = selection.first;
                });
              },
              showSelectedIcon: false,
              style: ButtonStyle(visualDensity: VisualDensity.compact),
            ),
            const SizedBox(height: 24),
            Expanded(
              child: switch (_mode) {
                ValidationMode.standalone => _buildStandaloneMode(context),
                ValidationMode.parsedoc => _buildParsedocMode(context),
              },
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildStandaloneMode(BuildContext context) {
    final text = _standaloneTextController.text;
    final query = _standaloneQueryController.text;
    final highlighted = Highlight(
      HighlightOptions(
        caseSensitive: _caseSensitive,
        strategy: _strategy,
        CSSClass: _exampleHighlightCssClass,
      ),
    ).highlight(text, query);

    final controls = ListView(
      children: [
        Text(
          'Standalone highlight',
          style: Theme.of(context).textTheme.titleLarge,
        ),
        const SizedBox(height: 16),
        TextField(
          key: const ValueKey('standalone-query'),
          controller: _standaloneQueryController,
          decoration: const InputDecoration(
            labelText: 'Query',
            border: OutlineInputBorder(),
          ),
        ),
        const SizedBox(height: 12),
        TextField(
          key: const ValueKey('standalone-text'),
          controller: _standaloneTextController,
          minLines: 6,
          maxLines: 10,
          decoration: const InputDecoration(
            labelText: 'Input text',
            border: OutlineInputBorder(),
          ),
        ),
        const SizedBox(height: 12),
        LayoutBuilder(
          builder: (context, constraints) {
            final narrow = constraints.maxWidth < 560;
            if (narrow) {
              return Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  _buildStrategyField(),
                  const SizedBox(height: 12),
                  _buildCaseSensitiveToggle(),
                ],
              );
            }

            return Row(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Expanded(child: _buildStrategyField()),
                const SizedBox(width: 16),
                Expanded(child: _buildCaseSensitiveToggle()),
              ],
            );
          },
        ),
      ],
    );

    final positions = highlighted.positions;
    final htmlOutput = highlighted.HTML;
    final trimOutput = highlighted.trim(18);

    final details = ListView(
      children: [
        _InfoCard(
          title: 'TextSpan preview',
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                'Rendered with RichText/TextSpan from inclusive Position ranges.',
                style: Theme.of(context).textTheme.bodySmall,
              ),
              const SizedBox(height: 8),
              _HighlightedTextPreview(text: text, positions: positions),
              const SizedBox(height: 8),
              Text('Positions: ${_formatPositions(positions)}'),
            ],
          ),
        ),
        const SizedBox(height: 16),
        _InfoCard(
          title: 'Rendered HTML preview',
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                'Rendered with flutter_html from the highlight HTML output.',
                style: Theme.of(context).textTheme.bodySmall,
              ),
              const SizedBox(height: 8),
              Html(data: htmlOutput),
            ],
          ),
        ),
        const SizedBox(height: 16),
        _InfoCard(
          title: 'Raw HTML string',
          child: _CodeBlock(text: htmlOutput),
        ),
        const SizedBox(height: 16),
        _InfoCard(
          title: 'Trim(18)',
          child: _CodeBlock(text: trimOutput),
        ),
      ],
    );

    return LayoutBuilder(
      builder: (context, constraints) {
        final narrow = constraints.maxWidth < 960;
        if (narrow) {
          return Column(
            children: [
              Expanded(child: controls),
              const SizedBox(height: 24),
              Expanded(child: details),
            ],
          );
        }

        return Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Expanded(child: controls),
            const SizedBox(width: 24),
            Expanded(child: details),
          ],
        );
      },
    );
  }

  Widget _buildParsedocMode(BuildContext context) {
    final source = _source;
    final record = _selectedRecord;
    final query = _parsedocQueryController.text;
    final highlightedTitle = record == null
        ? null
        : Highlight(
            const HighlightOptions(CSSClass: _exampleHighlightCssClass),
          ).highlight(record.title, query);
    final highlightedContent = record == null
        ? null
        : Highlight(
            const HighlightOptions(CSSClass: _exampleHighlightCssClass),
          ).highlight(record.content, query);

    final header = LayoutBuilder(
      builder: (context, constraints) {
        final narrow = constraints.maxWidth < 760;
        if (narrow) {
          return Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              FilledButton.icon(
                key: const ValueKey('choose-folder'),
                onPressed: _loading ? null : _chooseFolder,
                icon: const Icon(Icons.folder_open),
                label: const Text('Choose Folder'),
              ),
              const SizedBox(height: 12),
              TextField(
                key: const ValueKey('parsedoc-query'),
                controller: _parsedocQueryController,
                decoration: const InputDecoration(
                  hintText: 'Search parsed HTML or Markdown...',
                  border: OutlineInputBorder(),
                ),
              ),
            ],
          );
        }

        return Row(
          children: [
            FilledButton.icon(
              key: const ValueKey('choose-folder'),
              onPressed: _loading ? null : _chooseFolder,
              icon: const Icon(Icons.folder_open),
              label: const Text('Choose Folder'),
            ),
            const SizedBox(width: 16),
            Expanded(
              child: TextField(
                key: const ValueKey('parsedoc-query'),
                controller: _parsedocQueryController,
                decoration: const InputDecoration(
                  hintText: 'Search parsed HTML or Markdown...',
                  border: OutlineInputBorder(),
                ),
              ),
            ),
          ],
        );
      },
    );

    final resultsPane = _results.isEmpty
        ? const Center(
            child: Text(
              'Choose a folder to validate parsedoc-backed highlighting.',
            ),
          )
        : ListView.separated(
            itemCount: _results.length,
            separatorBuilder: (_, _) => const Divider(height: 1),
            itemBuilder: (context, index) {
              final item = _results[index];
              final preview = Highlight(
                const HighlightOptions(CSSClass: _exampleHighlightCssClass),
              ).highlight(item.record.content, query);
              return ListTile(
                title: Text(item.record.title),
                subtitle: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(item.record.pathLabel),
                    const SizedBox(height: 4),
                    _HighlightedTextPreview(
                      text: item.record.content,
                      positions: preview.positions,
                      maxLines: 2,
                    ),
                  ],
                ),
                selected: item.record == _selectedRecord,
                onTap: () {
                  setState(() {
                    _selectedRecord = item.record;
                  });
                },
              );
            },
          );

    final detailsPane = record == null
        ? const Center(child: Text('Select a result to inspect it.'))
        : ListView(
            children: [
              _InfoCard(
                title: 'Highlighted title',
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    _HighlightedTextPreview(
                      text: record.title,
                      positions: highlightedTitle!.positions,
                    ),
                    const SizedBox(height: 8),
                    _CodeBlock(text: highlightedTitle.HTML),
                  ],
                ),
              ),
              const SizedBox(height: 16),
              _InfoCard(
                title: 'Highlighted content',
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    _HighlightedTextPreview(
                      text: record.content,
                      positions: highlightedContent!.positions,
                    ),
                    const SizedBox(height: 8),
                    _CodeBlock(text: highlightedContent.HTML),
                  ],
                ),
              ),
              const SizedBox(height: 16),
              _InfoCard(
                title: 'Parsed record metadata',
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text('Path: ${record.pathLabel}'),
                    Text('Parsed path: ${record.parsedPath}'),
                    Text('Type: ${record.type}'),
                    Text('Format: ${record.format}'),
                  ],
                ),
              ),
              const SizedBox(height: 16),
              _InfoCard(
                title: 'Source preview',
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      record.format == 'markdown'
                          ? 'Rendered with flutter_markdown_plus MarkdownBody.'
                          : 'Shown as raw HTML source text.',
                      style: Theme.of(context).textTheme.bodySmall,
                    ),
                    const SizedBox(height: 8),
                    record.format == 'markdown'
                        ? MarkdownBody(data: record.displayBody)
                        : SelectableText(record.displayBody),
                  ],
                ),
              ),
            ],
          );

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        header,
        const SizedBox(height: 16),
        if (_loading) const LinearProgressIndicator(),
        if (_loading) const SizedBox(height: 16),
        Text(
          source == null
              ? 'No folder loaded yet.'
              : 'Indexed ${source.indexedCount} extracted records from ${source.discoveredCount} supported files in ${source.label}',
        ),
        if (source != null) ...[
          const SizedBox(height: 8),
          Text('Issues: ${source.issues.length}'),
        ],
        if (_error != null) ...[
          const SizedBox(height: 8),
          Text(
            _error!,
            style: TextStyle(color: Theme.of(context).colorScheme.error),
          ),
        ],
        const SizedBox(height: 16),
        Expanded(
          child: LayoutBuilder(
            builder: (context, constraints) {
              final narrow = constraints.maxWidth < 960;
              if (narrow) {
                return Column(
                  children: [
                    Expanded(child: resultsPane),
                    const SizedBox(height: 24),
                    Expanded(child: detailsPane),
                  ],
                );
              }

              return Row(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Expanded(child: resultsPane),
                  const SizedBox(width: 24),
                  Expanded(child: detailsPane),
                ],
              );
            },
          ),
        ),
      ],
    );
  }

  Widget _buildStrategyField() {
    return DropdownButtonFormField<String>(
      key: const ValueKey('standalone-strategy'),
      initialValue: _strategy,
      isExpanded: true,
      items: const [
        DropdownMenuItem(value: 'partialMatch', child: Text('Partial match')),
        DropdownMenuItem(
          value: 'wholeWordMatch',
          child: Text('Whole word match'),
        ),
        DropdownMenuItem(
          value: 'partialMatchFullWord',
          child: Text('Partial match, full word'),
        ),
      ],
      decoration: const InputDecoration(
        labelText: 'Strategy',
        border: OutlineInputBorder(),
      ),
      onChanged: (value) {
        if (value == null) {
          return;
        }
        setState(() {
          _strategy = value;
        });
      },
    );
  }

  Widget _buildCaseSensitiveToggle() {
    return SwitchListTile.adaptive(
      key: const ValueKey('case-sensitive-toggle'),
      contentPadding: EdgeInsets.zero,
      title: const Text('Case sensitive'),
      value: _caseSensitive,
      onChanged: (value) {
        setState(() {
          _caseSensitive = value;
        });
      },
    );
  }

  String _formatPositions(List<Position> positions) {
    if (positions.isEmpty) {
      return '[]';
    }
    return '[${positions.map((p) => '${p.start}-${p.end}').join(', ')}]';
  }
}

class _InfoCard extends StatelessWidget {
  const _InfoCard({required this.title, required this.child});

  final String title;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(title, style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 12),
            child,
          ],
        ),
      ),
    );
  }
}

class _HighlightedTextPreview extends StatelessWidget {
  const _HighlightedTextPreview({
    required this.text,
    required this.positions,
    this.maxLines,
  });

  final String text;
  final List<Position> positions;
  final int? maxLines;

  @override
  Widget build(BuildContext context) {
    return RichText(
      maxLines: maxLines,
      overflow: maxLines == null ? TextOverflow.visible : TextOverflow.ellipsis,
      text: TextSpan(
        style: Theme.of(context).textTheme.bodyMedium,
        children: _buildSpans(context),
      ),
    );
  }

  List<InlineSpan> _buildSpans(BuildContext context) {
    if (positions.isEmpty || text.isEmpty) {
      return [TextSpan(text: text)];
    }

    final sorted = [...positions]..sort((a, b) => a.start.compareTo(b.start));
    final spans = <InlineSpan>[];
    var cursor = 0;

    for (final position in sorted) {
      final start = position.start.clamp(0, text.length);
      final endExclusive = (position.end + 1).clamp(0, text.length);
      if (start > cursor) {
        spans.add(TextSpan(text: text.substring(cursor, start)));
      }
      if (endExclusive > start) {
        spans.add(
          TextSpan(
            text: text.substring(start, endExclusive),
            style: TextStyle(
              backgroundColor: Theme.of(context).colorScheme.tertiaryContainer,
              color: Theme.of(context).colorScheme.onTertiaryContainer,
              fontWeight: FontWeight.w700,
            ),
          ),
        );
      }
      cursor = endExclusive;
    }

    if (cursor < text.length) {
      spans.add(TextSpan(text: text.substring(cursor)));
    }

    return spans;
  }
}

class _CodeBlock extends StatelessWidget {
  const _CodeBlock({required this.text});

  final String text;

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: Theme.of(context).colorScheme.surfaceContainerHighest,
        borderRadius: BorderRadius.circular(12),
      ),
      child: SelectableText(
        text,
        style: Theme.of(
          context,
        ).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
      ),
    );
  }
}
0
likes
150
points
72
downloads

Documentation

Documentation
API reference

Publisher

verified publisherjasonholtdigital.com

Weekly Downloads

A pure Dart reimplementation of Orama's standalone highlight package.

Repository (GitHub)
View/report issues

Topics

#highlight #search #text #snippets #orama

License

Apache-2.0 (license)

More

Packages that depend on searchlight_highlight