Selkie
NAME
Selkie - High-level TUI framework built on Notcurses
SYNOPSIS
use Selkie;
my $app = Selkie::App.new;
my $root = Selkie::Layout::VBox.new(sizing => Sizing.flex);
$app.add-screen('main', $root);
$app.switch-screen('main');
my $log = Selkie::Widget::TextStream.new(sizing => Sizing.flex);
$root.add($log);
$log.append('Hello, Selkie!');
$app.on-key('ctrl+q', -> $ { $app.quit });
$app.run;DESCRIPTION
Selkie is the umbrella module ā use Selkie imports everything in one go, so you can refer to any class by its full name. Most apps work fine with this; finer-grained imports are available if you prefer to be explicit about what's in scope.
The framework itself is organised into several subnamespaces:
Core ā Selkie::App, Selkie::Widget, Selkie::Container, Selkie::Store, Selkie::ScreenManager
Support types ā Selkie::Sizing, Selkie::Style, Selkie::Theme, Selkie::Event
Layouts ā Selkie::Layout::VBox, Selkie::Layout::HBox, Selkie::Layout::Split
Display widgets ā Selkie::Widget::Text, Selkie::Widget::RichText, Selkie::Widget::TextStream, Selkie::Widget::Image, Selkie::Widget::ProgressBar, Selkie::Widget::Spinner, Selkie::Widget::PasswordStrength
Input widgets ā Selkie::Widget::TextInput, Selkie::Widget::MultiLineInput, Selkie::Widget::Button, Selkie::Widget::Checkbox, Selkie::Widget::RadioGroup, Selkie::Widget::Select
List / table widgets ā Selkie::Widget::ListView, Selkie::Widget::CardList, Selkie::Widget::ScrollView, Selkie::Widget::Table
Chrome widgets ā Selkie::Widget::Border, Selkie::Widget::Modal, Selkie::Widget::ConfirmModal, Selkie::Widget::FileBrowser, Selkie::Widget::Toast, Selkie::Widget::HelpOverlay
Navigation ā Selkie::Widget::TabBar, Selkie::Widget::CommandPalette
Chart widgets (opt-in) ā Selkie::Widget::Sparkline, Selkie::Widget::Plot, Selkie::Widget::BarChart, Selkie::Widget::Histogram, Selkie::Widget::Heatmap, Selkie::Widget::ScatterPlot, Selkie::Widget::LineChart, Selkie::Widget::Axis, Selkie::Widget::Legend, plus the Selkie::Plot::Palette, Selkie::Plot::Scaler, Selkie::Plot::Ticks primitives
Start with Selkie::App for the big picture, Selkie::Widget if you want to write your own widgets, and Selkie::Store for the reactive state model. Every module has runnable examples in its Pod.
The chart widgets and their plot primitives are not pulled in by use Selkie (they have heavier dependencies and aren't needed by most apps) ā import them explicitly when you reach for them, e.g. use Selkie::Widget::LineChart;.
MOUSE SUPPORT
Selkie has first-class mouse support across every prebuilt widget. You don't need to opt in ā Selkie::App enables button + drag events on construction (via notcurses_mice_enable), and the dispatcher routes presses, drags, releases, and the scroll wheel through the same handle-event path as keystrokes.
Dispatch model
Keyboard goes to the focused widget, then ancestors, then global keybinds (unchanged).
Mouse goes to the deepest widget under the cursor, then ancestors, then global keybinds.
A primary press on a focusable widget gives it focus before the event is delivered, so mouse-driven activation matches keyboard-driven activation.
Drag capture: a press on widget X routes subsequent drag and release events for that button to X, regardless of where the cursor is. This is what makes scrollbar drags and text-selection drags feel right when the cursor leaves the widget.
Modal isolation: clicks outside the active modal are dropped by default. Modals can opt in to dismiss-on-click-outside by passing
:dismiss-on-click-outsideto their constructor ā Selkie::Widget::HelpOverlay uses this.
Per-widget API
Same registration ergonomics as Selkie::Widget's on-key:
# Click anywhere on the widget ā primary button by default.
$widget.on-click: -> $ev {
say "clicked at row {$widget.local-row($ev)}, col {$widget.local-col($ev)}";
};
# Right-click via :button(3); :button(0) catches any button.
$widget.on-click: -> $ev { open-context-menu }, button => 3;
# Scroll wheel.
$widget.on-scroll: -> $ev {
given $ev.id {
when NCKEY_SCROLL_UP { ... }
when NCKEY_SCROLL_DOWN { ... }
}
};
# Drag (press + motion-with-button-held). Capture keeps these flowing
# even when the cursor leaves the widget's bounds.
$widget.on-drag: -> $ev {
say "drag now at row {$ev.y - $widget.abs-y}";
};
# Low-level escape hatches ā fire on every press / release.
$widget.on-mouse-down: -> $ev { ... };
$widget.on-mouse-up: -> $ev { ... };self.local-row($ev) and self.local-col($ev) translate the event's absolute screen coordinates into widget-local cells (returning -1 if the event is out of bounds). contains-point(y, x) exposes the same hit-test the framework uses internally.
Multi-click
Press events are annotated with their multiplicity in $ev.click-count: 1 for a single click, 2 for a double-click, 3 for a triple-click. The framework counts a press as a continuation of the previous click when it lands on the same cell with the same button within 300 ms. Selkie::Widget::TextInput uses double / triple click to select word / all; Selkie::Widget::FileBrowser uses double-click to descend into directories; Selkie::Widget::ListView uses double-click to fire on-activate.
Duplicate-press suppression
Terminal mouse drivers occasionally deliver two press events for one physical click. The dispatcher drops a press that repeats the previous one ā same button, same cell ā inside a suppression window, before click-to-focus, click counting, or delivery happen, so a driver double-fire can neither activate a control twice nor masquerade as a double-click. Widgets that expose a debounce-ms attribute (Button, Checkbox, Select ā default 120 ms, 0 disables) choose their own window; every other widget gets a 40 ms floor ā far below the ~80 ms of the fastest human double-click, so multi-click gestures are unaffected. Wheel events are exempt: they ride the button encoding but are not clicks, and flick-scrolling must never be throttled.
Built-in widget behaviours
Button, Checkbox ā primary click activates / toggles.
TabBar ā primary click activates the tab under the cursor.
RadioGroup, ListView, Table ā single-click selects the row; scroll wheel moves the cursor. Table also clicks the column header to cycle sort. ListView and Table fire
on-activateon double-click.Select ā click toggles the dropdown; scroll wheel scrolls the open dropdown; click on a dropdown row commits.
TextInput, MultiLineInput ā click positions the caret; drag selects; double-click selects the word; triple-click selects the line / buffer; Ctrl+A selects all; Ctrl+C / Ctrl+X emit on
on-copy/on-cut(the framework doesn't own the system clipboard ā wire OSC 52 / notcurses paste-buffer in your app).CardList ā click selects the card under the cursor; scroll wheel moves between cards.
ScrollView, TextStream ā scroll wheel scrolls; drag on the scrollbar column drags the thumb.
ConfirmModal, CommandPalette, FileBrowser ā clicks fall through to the embedded Button / ListView / TextInput, which handle them with their built-in behaviour.
HelpOverlay ā click outside dismisses; click the Close button to close.
Display-only widgets (Text, RichText, Image, Border, ProgressBar, Spinner, Toast, Legend, all charts) don't react to mouse ā Border passes through to its content.
AUTHOR
Matt Doughty [email protected]
COPYRIGHT AND LICENSE
Copyright 2026 Matt Doughty
This library is free software; you can redistribute it and/or modify it under the Artistic License 2.0.