quill_keys 1.1.0 copy "quill_keys: ^1.1.0" to clipboard
quill_keys: ^1.1.0 copied to clipboard

Vim-style modal keybinding package for Flutter — modes, chords, hints, TOML config.

quill_keys #

Vim-style modal keybinding package for Flutter desktop apps. Modes, sequential key chords, hint overlays, and TOML configuration — modeled after qutebrowser.

View the Flutter web example app: https://ninthhouse.studio/quill/

Note: if you use qutebrowser, it will not really work. Likewise if you use some kind of vim-mode, but most "standard" browsers should serve the example just fine.

Features #

  • Modal input: Normal, Insert, and Hint modes with a mode stack. Define custom modes by extending QuillMode.
  • Key chords: Sequential multi-key bindings (gt for "next tab") via trie-based matching with configurable timeout.
  • Hint system: qutebrowser-style hint labels over any widget. Home-row-first label generation, type a label to trigger its action.
  • TOML config: Human-readable binding files. Overlay user config on app defaults. Use different files as "profiles".
  • Named actions: All actions are strings. Bindings map chords to names; your app registers callbacks. Config and code stay decoupled.
  • Auto insert mode: Quill detects when a TextField gains focus and switches to Insert mode automatically. Escape returns to Normal and releases focus from the field.
  • Which-key support: After a configurable delay on partial chord input, exposes available continuations so apps can build which-key style discovery guides.
  • Status bar: Drop-in widget showing current mode and partial chord in progress.

Design Principle: Hint Everything #

Every clickable or interactive widget should be wrapped with QuillHint. This is the core UX contract of a Quill-powered app: anything a mouse user can click, a keyboard user can reach via hints.

This includes buttons, tabs, text fields, icons, list items, cards — anything tappable. For text fields specifically, use the onHint callback to request focus, which auto-triggers Insert mode:

QuillHint(
  actionName: 'focus-search',
  onHint: () => _searchFocusNode.requestFocus(),
  child: TextField(focusNode: _searchFocusNode, ...),
)

Quill provides the mechanism; your app wraps the widgets. The demo app (lib/main.dart) models this exhaustively as a reference.

Quick Start #

import 'package:quill_keys/quill.dart';

const tomlConfig = '''
[settings]
chord_timeout_ms = 1500
which_key_delay_ms = 400
hint_chars = "asdfghjkl"

[normal]
j = "scroll-down"
k = "scroll-up"
f = "hint-activate"
gt = "next-tab"
gg = "scroll-top"
G = "scroll-bottom"

[insert]
"<Escape>" = "normal-mode"
''';

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: QuillScope(
        config: QuillConfig.fromToml(tomlConfig),
        actions: {
          'scroll-down': () => print('down'),
          'scroll-up': () => print('up'),
          'next-tab': () => print('next tab'),
        },
        child: HintOverlay(
          child: Scaffold(
            body: Column(
              children: [
                QuillHint(
                  actionName: 'scroll-down',
                  child: ElevatedButton(
                    onPressed: () {},
                    child: Text('Scroll Down'),
                  ),
                ),
                // ... wrap every clickable widget with QuillHint
              ],
            ),
            bottomNavigationBar: QuillStatusBar(),
          ),
        ),
      ),
    );
  }
}

TOML Config Format #

[settings]
chord_timeout_ms = 1500       # Timeout for multi-key chords (ms)
which_key_delay_ms = 400      # Delay before showing which-key guide (ms)
hint_chars = "asdfghjkl"       # Characters used for hint labels (home row)

[normal]
j = "scroll-down"              # Single key binding
k = "scroll-up"
gt = "next-tab"                # Two-key chord
gg = "scroll-top"              # Repeated-key chord
G = "scroll-bottom"            # Shift+key (uppercase)
f = "hint-activate"            # Built-in: enters Hint mode
"<C-x>" = "close-tab"         # Ctrl+X
"<Escape>" = "normal-mode"     # Built-in: resets to Normal mode

[insert]
"<Escape>" = "normal-mode"
"<A-i>" = "normal-mode"        # Alt+I as alternative

[hint]
"<Escape>" = "normal-mode"

Key syntax #

Syntax Meaning
j Single key
gt Sequential chord: g then t
gg Repeated key chord
G Shift+G (uppercase = shifted)
"<Escape>" Named special key
"<C-x>" Ctrl+X
"<S-a>" Shift+A
"<A-x>" Alt+X

Profiles #

Profiles are just different TOML files. Two are built in:

final config = QuillConfig.fromToml(qutebrowserProfileToml);
// or
final config = QuillConfig.fromToml(vimProfileToml);

Overlay user customizations on top of defaults:

final merged = defaultConfig.merge(userConfig);

Modes #

Mode Behavior
Normal Key bindings are active. Default resting state.
Insert Keys pass through to the focused text field. Auto-activated on TextField focus. Escape exits and releases focus.
Hint Labels appear over QuillHint widgets. Type a label to trigger its action.

The mode stack supports nesting — entering Hint from Normal, then canceling, returns to Normal.

Widgets #

QuillScope #

Top-level widget. Wraps your app and intercepts key events. When leaving Insert mode, it automatically reclaims focus from text fields so Normal-mode bindings work immediately.

QuillScope(
  config: QuillConfig.fromToml(toml),
  actions: { 'my-action': () => doThing() },
  child: MyApp(),
)

Access the controller from anywhere below:

final controller = QuillScope.of(context);
controller.registerAction('new-action', () => doOtherThing());

QuillHint #

Wraps a widget to make it a hint target. Use actionName to invoke a registered action, or onHint for a direct callback:

// Invoke a registered action
QuillHint(
  actionName: 'open-settings',
  child: IconButton(icon: Icon(Icons.settings), onPressed: openSettings),
)

// Direct callback (e.g. focusing a text field)
QuillHint(
  actionName: 'focus-search',
  onHint: () => searchFocus.requestFocus(),
  child: TextField(focusNode: searchFocus, ...),
)

HintOverlay #

Renders floating labels. Place it above your QuillHint widgets:

HintOverlay(
  child: Scaffold(
    body: Column(children: [
      QuillHint(actionName: 'a', child: ButtonA()),
      QuillHint(actionName: 'b', child: ButtonB()),
    ]),
  ),
)

QuillStatusBar #

Shows [NORMAL], [INSERT], [HINT] and partial chords:

QuillStatusBar(
  modeStyle: TextStyle(fontWeight: FontWeight.bold),
  chordStyle: TextStyle(fontFamily: 'monospace'),
)

Which-Key Support #

Quill exposes chord continuation data so apps can build which-key style guides — popups that show available next keys after a partial chord.

final controller = QuillScope.of(context);

// After the which-key delay elapses on a partial chord:
if (controller.shouldShowWhichKey) {
  final continuations = controller.continuations;
  for (final c in continuations) {
    print('${c.key} → ${c.actionName ?? "..."}');
    // e.g. "t → next-tab", "T → prev-tab", "g → scroll-top"
  }
}

Configure the delay in TOML:

[settings]
which_key_delay_ms = 400   # default; set to 0 for immediate

Quill manages the timing; your app builds the UI. shouldShowWhichKey resets automatically on match, timeout, or mode change.

Architecture #

Pure-Dart core with zero Flutter imports, Flutter widget layer on top. See doc/architecture.md for the full design document.

lib/src/
  core/           # Pure Dart: mode stack, trie matcher, registry, config, labels
  widgets/        # Flutter: QuillScope, QuillHint, HintOverlay, QuillStatusBar
  defaults/       # Built-in profiles: qutebrowser, vim

Testing #

# Core unit tests (pure Dart, fast)
flutter test test/core/

# Widget tests
flutter test test/widgets/

# All tests
flutter test

Limitations (v1) #

  • Global scope only (no per-widget binding scopes)
  • No command mode or command palette
  • No count prefixes (5j)
  • No runtime rebinding
  • Greedy matching: can't bind both g and gt simultaneously

License #

See LICENSE.

0
likes
160
points
9
downloads

Documentation

API reference

Publisher

verified publisherninthhouse.studio

Weekly Downloads

Vim-style modal keybinding package for Flutter — modes, chords, hints, TOML config.

Repository (GitLab)
View/report issues

License

MIT (license)

Dependencies

flutter, toml

More

Packages that depend on quill_keys