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::Alpha, Selkie::Gradient, Selkie::Tween, Selkie::Theme, Selkie::Event, Selkie::BorderStyle, Selkie::Align
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, Selkie::Widget::GradientFill
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.