makepad-widgets becomes a front crate over widgets/core and widgets/families (each family a crate of its own, linked by features, so an app compiles only the families it uses); widgets/feedback is the Send feedback panel. The dev PRs (#1241-#1264) are ported to the new layout, as in the merge of dev into work. Covers work ebf442a94..2b1a41df4 for draw widgets xr code_editor: - widgets, storybook: the widget catalogue app, some eighty new widgets, a theme store with mixable style sheets, and the rule that a press belongs to - wm, widgets: the style tween starts with all eight weights, and the new style sheet takes the last number instead of macOS's - platform: the Android desk swipes at 120 Hz — offscreen render passes and framebuffers cached, SVG meshes kept, font families that complete - Button: activate from the keyboard when focused (#1241) - app_main!: only ship the fonts an app declares (#1247) - platform, widgets, wm: every Vulkan pass renders through a negative-height viewport again, so a Wayland or X11 window stands upright, and no capture - widgets: the supersampled resolve and the map's shadow mask sample their textures as stored, like every other render texture - widgets, platform: the last twelve warnings go - cef, video, platform: what Stage's browser needs from the shared layers - apps: what the CI box found - ScrollBar: add `show_handle` for a view that scrolls without a grabbable bar (#1254) - widgets: children a lookup discovers reach the dump, the snapshot and the flood searches - tests: the workspace suite, run as a whole for the first time, passes outside the example UI tests - examples: the UI tests pass, for the reasons they failed - libs: no warnings in the workspace check on any row - tests: the last three failures of the CI box's night - Linux: fix window chrome button hovers and how maximized/fullscreen windows work (#1255) - Tooltip: position anchored tooltips in the same draw (#1256) - tests: the three binaries over ten seconds in release come under it - widgets: let the host install script mods into every Splash isolate - widgets: a pooled test context forgets the last case's Escape claim - widgets: the pooled test context's resets replace the globals - RadioButton: fix its touch hover state and click-off behavior (#1258) - HtmlLink: fix its hover and pressed states (#1257) - RadioButton: take key focus on the click, not on the press (#1259) - feat(text): CoreText outline fallback for hvgl-only fonts (macOS) - draw text: the CoreText outline fallback is only ever resolved for a face whose outlines live solely in hvgl, and font-family diagnostics are the `f - platform, widgets, wm: a touch that is taken away is cancelled, never released - wm on Android: every app is its own process - widgets: touch lists scroll with Android's physics - task, cargo-makepad, widgets, audio: the task manager chooses its columns, graphs every process' network and disk traffic and installs itself as a D - widgets, draw: skeuomorphic surfaces light each other through an optional relief buffer - widgets, platform: a texture can light the relief buffer, menus take colour chips, and a style reload recompiles changed shader functions - widgets: relief surfaces can travel like mechanical keys, and dark surfaces take less neighbour light - platform, widgets, audio_route, ai: window crossfades and whole-frame presents, caption controls that click, themed menus, a stereo-pair audio tap, - platform, widgets, ai: a restyle recompiles nothing it already has, shows only complete frames, and a window can own its caption - widgets: relief surfaces can swing their light toward a point - widgets, builder: the big widget families are features, all on by default, and the Builder takes none of them - ai: Claude Desktop drives any app with the F10 panel - Constrain Splash external I/O to the host service bridge (#1243) - PortalList: stop following the end when scrolling to an earlier item (#1261) - draw: glass of a view that stopped drawing leaves the screen - widgets: the Widget lookup methods and with_script_vm_id are compiled once, not once per widget type - widgets: the library moves to makepad-widgets-core in widgets/core, and makepad-widgets becomes its front crate - widgets: the Window, widget tree and panel theme reach the tweaker, voice, AI slot and dock through hooks, not through their modules - widgets: every widget family is a crate of its own under widgets/families, and makepad-widgets links, re-exports and registers the ones its features - aichat, widgets: the chat reads the design feedback through the tweaker's hook, so linking the chat no longer needs the tweaker - apps, examples, libs: each crate builds only the widget families it uses, and the design overlay is each app's own default feature - widgets: the build scripts rerun on their own edits only, and makepad-widgets no longer reads MAKEPAD - feedback: Send feedback, a caption icon and a small panel that shows exactly what is sent - feedback: the panel's wording reads right and draws in any font - feedback: takes makepad-widgets without its default families - widgets: the empty parts of an app's caption bar drag the window again - ScrollBar: fade out when idle, like macOS overlay scrollers (#1264) - platform, draw: web builds start again - widgets: the keyboard reaches boxes and modals - widgets: a hover tooltip never sits under the mouse cursor - feedback: Send clipboard, and the dialog works from the keyboard Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1360 lines
49 KiB
Rust
1360 lines
49 KiB
Rust
//! CommandPalette — everything the app can do, found by typing at it.
|
|
//!
|
|
//! A menu is a map: you learn where a thing lives and reach for the same
|
|
//! place every time. A palette is the other trade — you say the name and it
|
|
//! comes to you — and it is the only way back to a command whose name you
|
|
//! remember and whose home you have forgotten. An app wants both, and this
|
|
//! one is shaped so that the SAME table of lines feeds the palette and the
|
|
//! shortcut sheet: each line of `commands` is either `"# Heading"` or
|
|
//! `"What it does = ctrl+p"`, exactly the format
|
|
//! [`crate::kbd::ShortcutHelp`] reads.
|
|
//!
|
|
//! Typing narrows the list by a subsequence match with the matched letters
|
|
//! marked, and every row draws its chord as key caps through a
|
|
//! [`crate::kbd::KbdGroup`] — so somebody who arrived by typing leaves
|
|
//! knowing the keys, and stops needing the palette for that command.
|
|
//!
|
|
//! # Ranking, and why the headings survive it
|
|
//!
|
|
//! Ranking and grouping usually fight: rank the whole list and the groups
|
|
//! interleave into nonsense, keep the groups and the best match is buried
|
|
//! three headings down. They are settled here by ranking BOTH. Inside a
|
|
//! group the commands are ordered by score; the groups themselves are
|
|
//! ordered by their own best command. The first line of the first group is
|
|
//! therefore the best match in the whole list — the cursor starts on it and
|
|
//! Enter runs it — and the sheet still reads as the sheet somebody wrote.
|
|
//! With no query every score is zero, ties fall back to the written order,
|
|
//! and the palette shows the table exactly as it stands.
|
|
//!
|
|
//! # What it deliberately does not do
|
|
//!
|
|
//! It does not bind the chord that opens it. Nothing here listens for a
|
|
//! global key; a host calls [`CommandPalette::open`] from wherever it keeps
|
|
//! its bindings, because a widget that bound its own opener would quietly
|
|
//! become the place that binding is defined, which is the last place anyone
|
|
//! would look for it.
|
|
//!
|
|
//! It does not run anything either. Choosing a row raises an action carrying
|
|
//! the command's ordinal and its label, and the host does the work — the
|
|
//! palette has no idea what any of these lines mean.
|
|
//!
|
|
//! It has no scrollbar. The list windows to `max_rows` lines around the
|
|
//! cursor and the keyboard walks it. A palette is a keyboard instrument; a
|
|
//! bar to drag would be furniture for a gesture nobody makes here.
|
|
//!
|
|
//! It searches labels, not chords. The marks have to land on the letters the
|
|
//! person typed, and a query that matched "ctrl" would mark nothing at all.
|
|
//! Searching by chord is what the shortcut sheet is for.
|
|
use crate::{
|
|
kbd::{parse_help_entries, HelpEntry, KbdGroup},
|
|
makepad_derive_widget::*,
|
|
makepad_draw::*,
|
|
text_input::{TextInput, TextInputAction},
|
|
widget::*,
|
|
widget_async::ScriptAsyncResult,
|
|
};
|
|
|
|
script_mod! {
|
|
use mod.prelude.widgets_internal.*
|
|
use mod.widgets.*
|
|
|
|
// The palette owns a TextInput and a KbdGroup, so this module has to
|
|
// register after both of theirs: a block's `use` only sees what already
|
|
// exists, and a slot filled from a name that is not there yet is an
|
|
// empty slot.
|
|
|
|
set_type_default() do #(DrawPaletteRow::script_shader(vm)){
|
|
..mod.draw.DrawQuad
|
|
}
|
|
|
|
set_type_default() do #(DrawPaletteText::script_shader(vm)){
|
|
..mod.draw.DrawText
|
|
}
|
|
|
|
mod.widgets.CommandPaletteBase = #(CommandPalette::register_widget(vm))
|
|
|
|
/** A search field over the commands an app can run, over an overlay that
|
|
* covers the window. */
|
|
mod.widgets.CommandPalette = set_type_default() do mod.widgets.CommandPaletteBase{
|
|
// Zero by zero, and `on_after_apply` pins it there. The palette is an
|
|
// overlay: it paints on a root turtle sized by the pass and never
|
|
// walks its parent's turtle, so the walk it reports upward must claim
|
|
// nothing. A `Fill` here would make it a deferred fill of its parent,
|
|
// taking a share of that parent's spare length whether or not it was
|
|
// ever opened.
|
|
width: 0.
|
|
height: 0.
|
|
|
|
/** the lines: "# Heading" opens a group, "What it does = ctrl+p" is a command */
|
|
commands: []
|
|
/** shown in place of the list when nothing matches */
|
|
empty_text: "No command matches"
|
|
/** the quiet line under the list; "" leaves it out */
|
|
hint_text: "\u{2191}\u{2193} to move \u{00b7} Enter to run \u{00b7} Esc to close"
|
|
|
|
/** how wide the panel is, in pixels 240..900 step 10 */
|
|
panel_width: 520.
|
|
/** how far below the top of the window the panel sits 0..400 step 4 */
|
|
top_margin: 96.
|
|
/** the least room left between the panel and the window edge 0..80 step 2 */
|
|
screen_margin: 24.
|
|
/** the most lines shown at once; a heading counts as a line 3..30 step 1 */
|
|
max_rows: 12
|
|
/** the room inside the panel edge 0..40 step 1 */
|
|
pad: 10.
|
|
/** the room either side of a row's text 0..40 step 1 */
|
|
row_pad: 10.
|
|
/** the least room between a label and its key caps 0..40 step 1 */
|
|
chord_gap: 12.
|
|
/** the height of one command row 16..48 step 1 */
|
|
row_height: 26.
|
|
/** the height of a heading row 16..48 step 1 */
|
|
heading_height: 26.
|
|
/** the height of the search field 16..60 step 1 */
|
|
search_height: 30.
|
|
/** the room under the search field 0..40 step 1 */
|
|
search_gap: 8.
|
|
/** the height of the key caps in a row 12..40 step 1 */
|
|
cap_height: 20.
|
|
/** the height of the hint line 0..40 step 1 */
|
|
hint_height: 20.
|
|
|
|
search: mod.widgets.TextInput{
|
|
empty_text: "Type a command"
|
|
}
|
|
chord: mod.widgets.KbdGroup{}
|
|
|
|
draw_scrim +: {
|
|
/** how far the window behind is dimmed 0..1 step 0.01 */
|
|
dim: uniform(0.55)
|
|
color: uniform(theme.color_scrim)
|
|
pixel: fn() {
|
|
// The scrim colour token is opaque — it is the colour the
|
|
// dimming is a tint OF, not the tint — so the alpha is ours.
|
|
return vec4(self.color.xyz, self.dim)
|
|
}
|
|
}
|
|
|
|
draw_panel +: {
|
|
/** corner rounding 0..24 step 0.5 */
|
|
radius: uniform(theme.radius_l)
|
|
/** outline width in pixels 0..3 step 0.5 */
|
|
border_size: uniform(1.0)
|
|
color: uniform(theme.color_surface_container_high)
|
|
border_color: uniform(theme.color_outline_variant)
|
|
pixel: fn() {
|
|
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
|
|
sdf.box(0.5, 0.5, self.rect_size.x - 1.0, self.rect_size.y - 1.0, self.radius)
|
|
sdf.fill_keep(self.color)
|
|
sdf.stroke(self.border_color, self.border_size)
|
|
return sdf.result
|
|
}
|
|
}
|
|
|
|
draw_row +: {
|
|
/** the band under the keyboard cursor 0..1 step 0.01 */
|
|
active: 0.0
|
|
/** the band under the pointer 0..1 step 0.01 */
|
|
hover: 0.0
|
|
/** corner rounding 0..16 step 0.5 */
|
|
radius: uniform(theme.radius_s)
|
|
color_hover: uniform(theme.color_opaque_u_1)
|
|
color_active: uniform(theme.color_primary_container)
|
|
pixel: fn() {
|
|
let sdf = Sdf2d.viewport(self.pos * self.rect_size)
|
|
sdf.box(0.5, 0.5, self.rect_size.x - 1.0, self.rect_size.y - 1.0, self.radius)
|
|
let band = mix(self.color_hover, self.color_active, self.active)
|
|
sdf.fill(vec4(band.xyz, band.w * max(self.hover, self.active)))
|
|
return sdf.result
|
|
}
|
|
}
|
|
|
|
draw_label +: {
|
|
/** a letter the search matched 0..1 step 0.01 */
|
|
matched: 0.0
|
|
/** the row under the keyboard cursor 0..1 step 0.01 */
|
|
active: 0.0
|
|
color: theme.color_text
|
|
color_active: uniform(theme.color_on_primary_container)
|
|
color_match: uniform(theme.color_primary)
|
|
// Line spacing of one, so the line box IS the ink box: the runs
|
|
// are placed against the row by arithmetic, not by the layouter,
|
|
// and the two only agree when the line carries no leading.
|
|
text_style: theme.font_regular{font_size: theme.font_size_p, line_spacing: 1.0}
|
|
get_color: fn() {
|
|
// The marks are the accent whatever row they are on, so the
|
|
// letters you typed read the same as the cursor moves over
|
|
// them.
|
|
let ink = self.color
|
|
.mix(self.color_active, self.active)
|
|
.mix(self.color_match, self.matched)
|
|
return ink
|
|
}
|
|
}
|
|
|
|
draw_heading +: {
|
|
color: theme.color_text_meta
|
|
text_style: theme.font_bold{font_size: theme.font_size_p, line_spacing: 1.0}
|
|
}
|
|
|
|
draw_meta +: {
|
|
color: theme.color_text_meta
|
|
text_style: theme.font_regular{font_size: theme.font_size_p, line_spacing: 1.0}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Where a glyph's ink starts below the y handed to `draw_abs`, as a share of
|
|
/// the font size. `draw_abs` takes the top of the LINE box, not the ink, and
|
|
/// a run of capitals has no descender to fill the bottom of the line, so a
|
|
/// centred line box leaves the text riding high.
|
|
const INK_TOP: f64 = 0.30;
|
|
|
|
/// The narrowest the panel may be squeezed to before it stops giving ground
|
|
/// to the window edge; under this a command list is unreadable anyway.
|
|
const MIN_PANEL_WIDTH: f64 = 200.0;
|
|
|
|
/// Where one line of text goes so that it sits in the middle of `rect`.
|
|
fn ink_y(rect: Rect, font_size: f64) -> f64 {
|
|
rect.pos.y + (rect.size.y - font_size) * 0.5 - font_size * INK_TOP
|
|
}
|
|
|
|
/// How far a drawn run advances the pen.
|
|
///
|
|
/// This is `crate::badge::measure` without its slack. The slack is there so
|
|
/// a box drawn AROUND a run clears the last glyph's side bearing; the runs of
|
|
/// one label are drawn end to end, and adding it between them would open a
|
|
/// two-pixel hole in the middle of every marked word.
|
|
fn run_width(draw_text: &DrawText, cx: &mut Cx2d, text: &str) -> f64 {
|
|
draw_text
|
|
.prepare_single_line_run(cx, text)
|
|
.map(|run| run.width_in_lpxs as f64)
|
|
.unwrap_or_else(|| text.chars().count() as f64 * draw_text.text_style.font_size as f64 * 0.62)
|
|
}
|
|
|
|
/// Where the search letters landed in a label, and what that alignment
|
|
/// scored.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct PaletteMatch {
|
|
pub score: i32,
|
|
/// Char indices into the label, ascending.
|
|
pub marks: Vec<usize>,
|
|
}
|
|
|
|
/// What one matched letter is worth wherever it falls.
|
|
const MATCH_BASE: i32 = 4;
|
|
/// A letter that starts a word is the letter somebody meant to type.
|
|
const WORD_START_BONUS: i32 = 6;
|
|
/// A letter taken immediately after the one before it.
|
|
const RUN_BONUS: i32 = 5;
|
|
/// How far into a label the earliness penalty keeps counting. Past this
|
|
/// everything is simply "late", so a long label does not lose to a short one
|
|
/// on its tail alone.
|
|
const LATE_CAP: i32 = 12;
|
|
|
|
/// Whether `at` begins a word. The ORIGINAL characters are read, not the
|
|
/// folded ones, so a run of camel case counts as the several words it is.
|
|
fn word_start(chars: &[char], at: usize) -> bool {
|
|
if at == 0 {
|
|
return true;
|
|
}
|
|
let prev = chars[at - 1];
|
|
!prev.is_alphanumeric() || (prev.is_lowercase() && chars[at].is_uppercase())
|
|
}
|
|
|
|
fn place_score(chars: &[char], at: usize) -> i32 {
|
|
MATCH_BASE
|
|
+ if word_start(chars, at) { WORD_START_BONUS } else { 0 }
|
|
- (at as i32).min(LATE_CAP)
|
|
}
|
|
|
|
/// Lowercase one character at a time rather than the whole string: folding a
|
|
/// string can change how many characters it has, and the marks index the
|
|
/// original.
|
|
fn folded(chars: &[char]) -> Vec<char> {
|
|
chars
|
|
.iter()
|
|
.map(|c| c.to_lowercase().next().unwrap_or(*c))
|
|
.collect()
|
|
}
|
|
|
|
/// The best way to read `query` as a subsequence of `label`, or `None` when
|
|
/// it is not one at all. An empty query matches everything at zero.
|
|
///
|
|
/// Best, not first. A greedy left-to-right scan takes each letter at its
|
|
/// earliest chance, which for "fa" against "Frame All" marks the F and the a
|
|
/// of *Frame* — the letters somebody typed are the initials, and marking the
|
|
/// wrong ones makes the palette look as though it matched by accident. So
|
|
/// every alignment is scored and the best one wins: the search runs over the
|
|
/// choices rather than down the first path it finds.
|
|
pub fn fuzzy_match(label: &str, query: &str) -> Option<PaletteMatch> {
|
|
let needle: Vec<char> = query.trim().chars().collect();
|
|
if needle.is_empty() {
|
|
return Some(PaletteMatch { score: 0, marks: Vec::new() });
|
|
}
|
|
let hay: Vec<char> = label.chars().collect();
|
|
if needle.len() > hay.len() {
|
|
return None;
|
|
}
|
|
let hay_low = folded(&hay);
|
|
let needle_low = folded(&needle);
|
|
let (m, n) = (needle_low.len(), hay.len());
|
|
|
|
const NONE: i32 = i32::MIN;
|
|
// best[j * n + p]: the best total for needle[j..] when needle[j] is taken
|
|
// at p, NOT counting the run bonus needle[j] earns from needle[j - 1] —
|
|
// that belongs to whoever chose p, since only they know where the letter
|
|
// before it went. Quadratic in the label length, which costs nothing for
|
|
// the short labels a command list holds.
|
|
let mut best = vec![NONE; m * n];
|
|
let mut pick = vec![usize::MAX; m * n];
|
|
for j in (0..m).rev() {
|
|
for p in 0..n {
|
|
if hay_low[p] != needle_low[j] {
|
|
continue;
|
|
}
|
|
if j + 1 == m {
|
|
best[j * n + p] = place_score(&hay, p);
|
|
continue;
|
|
}
|
|
let mut tail = NONE;
|
|
let mut tail_at = usize::MAX;
|
|
for q in p + 1..n {
|
|
let next = best[(j + 1) * n + q];
|
|
if next == NONE {
|
|
continue;
|
|
}
|
|
let with_run = next + if q == p + 1 { RUN_BONUS } else { 0 };
|
|
if with_run > tail {
|
|
tail = with_run;
|
|
tail_at = q;
|
|
}
|
|
}
|
|
if tail != NONE {
|
|
best[j * n + p] = place_score(&hay, p) + tail;
|
|
pick[j * n + p] = tail_at;
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut at = usize::MAX;
|
|
let mut score = NONE;
|
|
for p in 0..n {
|
|
if best[p] > score {
|
|
score = best[p];
|
|
at = p;
|
|
}
|
|
}
|
|
if score == NONE {
|
|
return None;
|
|
}
|
|
let mut marks = Vec::with_capacity(m);
|
|
let mut j = 0;
|
|
while at != usize::MAX {
|
|
marks.push(at);
|
|
at = pick[j * n + at];
|
|
j += 1;
|
|
}
|
|
Some(PaletteMatch { score, marks })
|
|
}
|
|
|
|
/// One line of the list as it will be drawn.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum PaletteLine {
|
|
Heading(String),
|
|
Command {
|
|
/// The command's ordinal among the command rows AS WRITTEN, which is
|
|
/// what a host's own array of commands is indexed by. It does not
|
|
/// move when the ranking does.
|
|
index: usize,
|
|
label: String,
|
|
shortcut: String,
|
|
/// Char indices into `label` that the query matched.
|
|
marks: Vec<usize>,
|
|
},
|
|
}
|
|
|
|
/// The lines a query leaves, in the order they are drawn.
|
|
///
|
|
/// Commands are ranked inside their group and the groups are ranked by their
|
|
/// own best command, so the first command of the first group is the best
|
|
/// match in the whole list. A group that lost every command loses its heading
|
|
/// with them.
|
|
pub fn rank_commands(entries: &[HelpEntry], query: &str) -> Vec<PaletteLine> {
|
|
// Commands written before any heading belong to a group with no title.
|
|
let mut groups: Vec<(Option<String>, Vec<(i32, usize, PaletteLine)>)> = vec![(None, Vec::new())];
|
|
let mut ordinal = 0usize;
|
|
for entry in entries {
|
|
match entry {
|
|
HelpEntry::Heading(text) => groups.push((Some(text.clone()), Vec::new())),
|
|
HelpEntry::Row { label, shortcut } => {
|
|
let index = ordinal;
|
|
ordinal += 1;
|
|
let Some(hit) = fuzzy_match(label, query) else {
|
|
continue;
|
|
};
|
|
let line = PaletteLine::Command {
|
|
index,
|
|
label: label.clone(),
|
|
shortcut: shortcut.clone(),
|
|
marks: hit.marks,
|
|
};
|
|
// There is always a group open: the untitled one, at worst.
|
|
if let Some(group) = groups.last_mut() {
|
|
group.1.push((hit.score, index, line));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut kept: Vec<(i32, usize, Option<String>, Vec<PaletteLine>)> = Vec::new();
|
|
for (at, (heading, mut rows)) in groups.into_iter().enumerate() {
|
|
if rows.is_empty() {
|
|
continue;
|
|
}
|
|
rows.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
|
|
let best = rows[0].0;
|
|
kept.push((best, at, heading, rows.into_iter().map(|(_, _, line)| line).collect()));
|
|
}
|
|
// The written position is the tie-break at both levels, so an empty
|
|
// query — every score zero — reproduces the table exactly as written.
|
|
kept.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
|
|
|
|
let mut lines = Vec::new();
|
|
for (_, _, heading, rows) in kept {
|
|
if let Some(heading) = heading {
|
|
lines.push(PaletteLine::Heading(heading));
|
|
}
|
|
lines.extend(rows);
|
|
}
|
|
lines
|
|
}
|
|
|
|
/// The label split into runs, each flagged as matched or not, so a run is one
|
|
/// call to the text layer at one colour.
|
|
pub fn mark_runs(label: &str, marks: &[usize]) -> Vec<(bool, String)> {
|
|
let mut out: Vec<(bool, String)> = Vec::new();
|
|
for (at, ch) in label.chars().enumerate() {
|
|
// The marks are ascending, which is what makes this a search rather
|
|
// than a scan of the whole list per character.
|
|
let hit = marks.binary_search(&at).is_ok();
|
|
match out.last_mut() {
|
|
Some((flag, text)) if *flag == hit => text.push(ch),
|
|
_ => out.push((hit, ch.to_string())),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Where the window over the list starts, so that `cursor` is inside it and
|
|
/// the last line is never scrolled past.
|
|
pub fn palette_window_top(len: usize, cursor: Option<usize>, top: usize, max_rows: usize) -> usize {
|
|
let max = max_rows.max(1);
|
|
let Some(at) = cursor else {
|
|
return 0;
|
|
};
|
|
let mut top = top;
|
|
if at < top {
|
|
top = at;
|
|
} else if at >= top + max {
|
|
top = at + 1 - max;
|
|
}
|
|
top.min(len.saturating_sub(max))
|
|
}
|
|
|
|
/// The band under a row: one for the keyboard cursor, one for the pointer.
|
|
#[derive(Script, ScriptHook)]
|
|
#[repr(C)]
|
|
pub struct DrawPaletteRow {
|
|
#[deref]
|
|
draw_super: DrawQuad,
|
|
#[live]
|
|
active: f32,
|
|
#[live]
|
|
hover: f32,
|
|
}
|
|
|
|
/// A run of a label, told whether the search matched it and whether the row
|
|
/// it is on holds the cursor.
|
|
#[derive(Script, ScriptHook)]
|
|
#[repr(C)]
|
|
pub struct DrawPaletteText {
|
|
#[deref]
|
|
draw_super: DrawText,
|
|
#[live]
|
|
matched: f32,
|
|
#[live]
|
|
active: f32,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Default)]
|
|
pub enum CommandPaletteAction {
|
|
/// A command was chosen. Carries its ordinal among the command rows as
|
|
/// written and the label it was chosen by.
|
|
Ran { index: usize, label: String, shortcut: String },
|
|
/// Closed by the person rather than by the host, with nothing run.
|
|
Dismissed,
|
|
#[default]
|
|
None,
|
|
}
|
|
|
|
#[derive(Script, Widget)]
|
|
pub struct CommandPalette {
|
|
#[uid]
|
|
uid: WidgetUid,
|
|
#[source]
|
|
source: ScriptObjectRef,
|
|
#[walk]
|
|
walk: Walk,
|
|
#[layout]
|
|
layout: Layout,
|
|
|
|
/// The dimming layer, which is also what catches every press meant for
|
|
/// whatever is underneath.
|
|
#[redraw]
|
|
#[live]
|
|
draw_scrim: DrawQuad,
|
|
#[live]
|
|
draw_panel: DrawQuad,
|
|
#[live]
|
|
draw_row: DrawPaletteRow,
|
|
#[live]
|
|
draw_label: DrawPaletteText,
|
|
#[live]
|
|
draw_heading: DrawText,
|
|
/// The empty message and the hint line: the two quiet ones.
|
|
#[live]
|
|
draw_meta: DrawText,
|
|
/// One chord, drawn once per row.
|
|
#[live]
|
|
chord: KbdGroup,
|
|
#[live]
|
|
search: TextInput,
|
|
|
|
/// The lines: "# Heading" opens a group, "What it does = ctrl+p" is a
|
|
/// command.
|
|
#[live]
|
|
pub commands: Vec<String>,
|
|
#[live("No command matches".to_string())]
|
|
pub empty_text: String,
|
|
/// The quiet line under the list; empty leaves it out.
|
|
#[live]
|
|
pub hint_text: String,
|
|
|
|
#[live(520.0)]
|
|
pub panel_width: f64,
|
|
#[live(96.0)]
|
|
pub top_margin: f64,
|
|
#[live(24.0)]
|
|
pub screen_margin: f64,
|
|
#[live(12)]
|
|
pub max_rows: usize,
|
|
#[live(10.0)]
|
|
pub pad: f64,
|
|
#[live(10.0)]
|
|
pub row_pad: f64,
|
|
#[live(12.0)]
|
|
pub chord_gap: f64,
|
|
#[live(26.0)]
|
|
pub row_height: f64,
|
|
#[live(26.0)]
|
|
pub heading_height: f64,
|
|
#[live(30.0)]
|
|
pub search_height: f64,
|
|
#[live(8.0)]
|
|
pub search_gap: f64,
|
|
#[live(20.0)]
|
|
pub cap_height: f64,
|
|
#[live(20.0)]
|
|
pub hint_height: f64,
|
|
#[live(true)]
|
|
#[visible]
|
|
visible: bool,
|
|
|
|
/// The palette paints on a list of its own so it can be over everything
|
|
/// that was drawn before it.
|
|
#[rust]
|
|
draw_list: Option<DrawList2d>,
|
|
#[rust]
|
|
shown: bool,
|
|
#[rust]
|
|
query: String,
|
|
#[rust]
|
|
lines: Vec<PaletteLine>,
|
|
/// Index into `lines`, always at a `PaletteLine::Command`.
|
|
#[rust]
|
|
cursor: Option<usize>,
|
|
#[rust]
|
|
top: usize,
|
|
#[rust]
|
|
hover: Option<usize>,
|
|
/// Set by `open`, spent by the first draw that gives the field an area:
|
|
/// focus cannot be taken before there is something to take it with.
|
|
#[rust]
|
|
want_focus: bool,
|
|
/// Where each command row landed this draw, and which line it is.
|
|
#[rust]
|
|
rows: Vec<(usize, Rect)>,
|
|
#[rust]
|
|
panel_rect: Rect,
|
|
}
|
|
|
|
impl ScriptHook for CommandPalette {
|
|
fn on_after_new(&mut self, vm: &mut ScriptVm) {
|
|
self.draw_list = Some(DrawList2d::script_new(vm));
|
|
self.rebuild();
|
|
}
|
|
|
|
fn on_after_apply(
|
|
&mut self,
|
|
vm: &mut ScriptVm,
|
|
_apply: &Apply,
|
|
_scope: &mut Scope,
|
|
_value: ScriptValue,
|
|
) {
|
|
// Forced here rather than only left out of the DSL, so an instance
|
|
// writing `CommandPalette{height: Fill}` cannot make the palette a
|
|
// deferred fill of the column it happens to be declared in.
|
|
self.walk = Walk::empty();
|
|
self.rebuild();
|
|
vm.with_cx_mut(|cx| {
|
|
if let Some(draw_list) = &self.draw_list {
|
|
draw_list.redraw(cx);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
impl CommandPalette {
|
|
/// Re-read the table and re-rank it against the query. Cheap enough to do
|
|
/// on every keystroke: the table is tens of lines, not thousands.
|
|
fn rebuild(&mut self) {
|
|
let entries = parse_help_entries(&self.commands);
|
|
self.lines = rank_commands(&entries, &self.query);
|
|
self.cursor = self.first_command();
|
|
self.top = 0;
|
|
self.hover = None;
|
|
}
|
|
|
|
fn first_command(&self) -> Option<usize> {
|
|
self.lines
|
|
.iter()
|
|
.position(|line| matches!(line, PaletteLine::Command { .. }))
|
|
}
|
|
|
|
/// The lines that are commands, in drawn order.
|
|
fn command_lines(&self) -> Vec<usize> {
|
|
self.lines
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, line)| matches!(line, PaletteLine::Command { .. }))
|
|
.map(|(at, _)| at)
|
|
.collect()
|
|
}
|
|
|
|
fn redraw_overlay(&mut self, cx: &mut Cx) {
|
|
if let Some(draw_list) = &self.draw_list {
|
|
draw_list.redraw(cx);
|
|
}
|
|
self.draw_scrim.redraw(cx);
|
|
}
|
|
|
|
/// Move the cursor by `delta` commands. The arrows wrap, which is what
|
|
/// makes the last command one keypress from the first; a page does not,
|
|
/// because a page that came round would lose your place entirely.
|
|
fn step_cursor(&mut self, cx: &mut Cx, delta: isize, wrap: bool) {
|
|
let commands = self.command_lines();
|
|
if commands.is_empty() {
|
|
self.cursor = None;
|
|
return;
|
|
}
|
|
let now = self
|
|
.cursor
|
|
.and_then(|at| commands.iter().position(|line| *line == at))
|
|
.unwrap_or(0) as isize;
|
|
let len = commands.len() as isize;
|
|
let next = if wrap {
|
|
(now + delta).rem_euclid(len)
|
|
} else {
|
|
(now + delta).clamp(0, len - 1)
|
|
};
|
|
self.cursor = Some(commands[next as usize]);
|
|
// The pointer is somewhere else entirely; leaving its band lit while
|
|
// the keyboard walks away from it says two rows are chosen.
|
|
self.hover = None;
|
|
self.redraw_overlay(cx);
|
|
}
|
|
|
|
/// Keys the palette owns, before the search field can eat them. Returns
|
|
/// true when the key was spent here.
|
|
fn handle_nav_key(&mut self, cx: &mut Cx, ke: &KeyEvent) -> bool {
|
|
match ke.key_code {
|
|
KeyCode::ArrowDown => {
|
|
self.step_cursor(cx, 1, true);
|
|
true
|
|
}
|
|
KeyCode::ArrowUp => {
|
|
self.step_cursor(cx, -1, true);
|
|
true
|
|
}
|
|
KeyCode::PageDown => {
|
|
self.step_cursor(cx, self.max_rows.max(1) as isize, false);
|
|
true
|
|
}
|
|
KeyCode::PageUp => {
|
|
self.step_cursor(cx, -(self.max_rows.max(1) as isize), false);
|
|
true
|
|
}
|
|
KeyCode::ReturnKey => {
|
|
// Nothing matched: Enter does nothing at all and the query
|
|
// stays editable, rather than running whatever is nearest.
|
|
if let Some(at) = self.cursor {
|
|
self.run(cx, at);
|
|
}
|
|
true
|
|
}
|
|
KeyCode::Escape => {
|
|
self.dismiss(cx);
|
|
true
|
|
}
|
|
// Home and End belong to the caret. The query is a few characters
|
|
// long and the arrows wrap, so the list loses nothing by leaving
|
|
// the text field the keys a text field is expected to have.
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn run(&mut self, cx: &mut Cx, at: usize) {
|
|
let line = self.lines.get(at).cloned();
|
|
let Some(PaletteLine::Command { index, label, shortcut, .. }) = line else {
|
|
return;
|
|
};
|
|
self.close(cx);
|
|
cx.widget_action(self.uid, CommandPaletteAction::Ran { index, label, shortcut });
|
|
}
|
|
|
|
/// Closed by the person: the host hears about it, because a host that
|
|
/// only listened for a command would go on believing the palette was up.
|
|
fn dismiss(&mut self, cx: &mut Cx) {
|
|
if !self.shown {
|
|
return;
|
|
}
|
|
self.close(cx);
|
|
cx.widget_action(self.uid, CommandPaletteAction::Dismissed);
|
|
}
|
|
|
|
pub fn is_open(&self) -> bool {
|
|
self.shown
|
|
}
|
|
|
|
/// Show the palette with an empty query and the cursor on the first
|
|
/// command. It always opens fresh: a palette that reopened holding the
|
|
/// last search would answer the next keystroke with the wrong list.
|
|
pub fn open(&mut self, cx: &mut Cx) {
|
|
if self.shown {
|
|
return;
|
|
}
|
|
self.shown = true;
|
|
self.query.clear();
|
|
self.search.set_text(cx, "");
|
|
self.rebuild();
|
|
self.want_focus = true;
|
|
self.redraw_overlay(cx);
|
|
}
|
|
|
|
pub fn close(&mut self, cx: &mut Cx) {
|
|
if !self.shown {
|
|
return;
|
|
}
|
|
self.shown = false;
|
|
self.hover = None;
|
|
self.rows.clear();
|
|
cx.unblock_scrolling_within_area(self.draw_panel.area());
|
|
cx.revert_key_focus();
|
|
self.redraw_overlay(cx);
|
|
}
|
|
|
|
pub fn toggle(&mut self, cx: &mut Cx) {
|
|
if self.shown {
|
|
self.close(cx);
|
|
} else {
|
|
self.open(cx);
|
|
}
|
|
}
|
|
|
|
pub fn set_commands(&mut self, cx: &mut Cx, commands: Vec<String>) {
|
|
if self.commands == commands {
|
|
return;
|
|
}
|
|
self.commands = commands;
|
|
self.rebuild();
|
|
self.redraw_overlay(cx);
|
|
}
|
|
|
|
pub fn query(&self) -> &str {
|
|
&self.query
|
|
}
|
|
|
|
/// Filter the list. The field is set along with the model, so a query
|
|
/// pushed by the host and one typed into the box leave the same state.
|
|
pub fn set_query(&mut self, cx: &mut Cx, query: &str) {
|
|
if self.query == query {
|
|
return;
|
|
}
|
|
self.query = query.to_string();
|
|
self.rebuild();
|
|
if self.search.text() != query {
|
|
self.search.set_text(cx, query);
|
|
}
|
|
self.redraw_overlay(cx);
|
|
}
|
|
|
|
/// How many commands the query leaves; a test waits on this rather than
|
|
/// on pixels.
|
|
pub fn matches(&self) -> usize {
|
|
self.command_lines().len()
|
|
}
|
|
|
|
/// The label the cursor is on, or nothing.
|
|
pub fn highlighted(&self) -> Option<String> {
|
|
match self.lines.get(self.cursor?) {
|
|
Some(PaletteLine::Command { label, .. }) => Some(label.clone()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn draw_body(&mut self, cx: &mut Cx2d, scope: &mut Scope, pass: Vec2d) {
|
|
let margin = self.screen_margin.max(0.0);
|
|
let panel_w = self
|
|
.panel_width
|
|
.min((pass.x - margin * 2.0).max(MIN_PANEL_WIDTH))
|
|
.max(MIN_PANEL_WIDTH);
|
|
|
|
self.top = palette_window_top(self.lines.len(), self.cursor, self.top, self.max_rows);
|
|
let count = self.max_rows.max(1).min(self.lines.len().saturating_sub(self.top));
|
|
let visible: Vec<PaletteLine> = self.lines[self.top..self.top + count].to_vec();
|
|
|
|
let list_h: f64 = if visible.is_empty() {
|
|
self.row_height
|
|
} else {
|
|
visible
|
|
.iter()
|
|
.map(|line| match line {
|
|
PaletteLine::Heading(_) => self.heading_height,
|
|
PaletteLine::Command { .. } => self.row_height,
|
|
})
|
|
.sum()
|
|
};
|
|
let hint_h = if self.hint_text.is_empty() { 0.0 } else { self.hint_height };
|
|
let panel_h = self.pad * 2.0 + self.search_height + self.search_gap + list_h + hint_h;
|
|
|
|
let x = ((pass.x - panel_w) * 0.5).max(margin).floor();
|
|
// Near the top, not centred: the list grows downward from the field,
|
|
// and a panel that centred itself would move under the reading eye
|
|
// every time a keystroke changed how many rows there are.
|
|
let y = self.top_margin.min((pass.y - panel_h - margin).max(margin)).floor();
|
|
|
|
self.draw_panel
|
|
.begin(cx, Walk::fixed(panel_w, panel_h).with_abs_pos(dvec2(x, y)), self.layout);
|
|
let panel = cx.turtle().rect();
|
|
let left = panel.pos.x + self.pad;
|
|
let width = (panel.size.x - self.pad * 2.0).max(0.0);
|
|
let mut cy = panel.pos.y + self.pad;
|
|
|
|
let field = Walk {
|
|
abs_pos: Some(dvec2(left, cy)),
|
|
width: Size::Fixed(width),
|
|
height: Size::Fixed(self.search_height),
|
|
..Walk::default()
|
|
};
|
|
let _ = self.search.draw_walk(cx, scope, field);
|
|
cy += self.search_height + self.search_gap;
|
|
|
|
self.rows.clear();
|
|
if visible.is_empty() {
|
|
let rect = Rect { pos: dvec2(left, cy), size: dvec2(width, self.row_height) };
|
|
let size = self.draw_meta.text_style.font_size as f64;
|
|
let text = self.empty_text.clone();
|
|
self.draw_meta
|
|
.draw_abs(cx, dvec2(left + self.row_pad, ink_y(rect, size)), &text);
|
|
cy += self.row_height;
|
|
}
|
|
|
|
for (offset, line) in visible.iter().enumerate() {
|
|
let at = self.top + offset;
|
|
match line {
|
|
PaletteLine::Heading(text) => {
|
|
let rect = Rect { pos: dvec2(left, cy), size: dvec2(width, self.heading_height) };
|
|
let size = self.draw_heading.text_style.font_size as f64;
|
|
self.draw_heading
|
|
.draw_abs(cx, dvec2(left + self.row_pad, ink_y(rect, size)), text);
|
|
cy += self.heading_height;
|
|
}
|
|
PaletteLine::Command { label, shortcut, marks, .. } => {
|
|
let rect = Rect { pos: dvec2(left, cy), size: dvec2(width, self.row_height) };
|
|
let active = self.cursor == Some(at);
|
|
let hover = self.hover == Some(at) && !active;
|
|
if active || hover {
|
|
self.draw_row.active = if active { 1.0 } else { 0.0 };
|
|
self.draw_row.hover = if hover { 1.0 } else { 0.0 };
|
|
self.draw_row.draw_abs(cx, rect);
|
|
}
|
|
// The chord is measured before it is placed: a row reads
|
|
// right to left from the keys, so they end at the edge.
|
|
self.chord.shortcut = shortcut.clone();
|
|
let chord = self.chord.chord_size(cx, self.cap_height);
|
|
if chord.x > 0.0 {
|
|
let chord_rect = Rect {
|
|
pos: dvec2(
|
|
rect.pos.x + rect.size.x - self.row_pad - chord.x,
|
|
rect.pos.y + (rect.size.y - chord.y) * 0.5,
|
|
),
|
|
size: chord,
|
|
};
|
|
self.chord.draw_chord(cx, chord_rect);
|
|
}
|
|
// A turtle of its own, so a label longer than the room it
|
|
// has is cut at the keys rather than running under them.
|
|
let reserved = if chord.x > 0.0 { chord.x + self.chord_gap } else { 0.0 };
|
|
let text_w = (rect.size.x - self.row_pad * 2.0 - reserved).max(1.0);
|
|
cx.begin_turtle(
|
|
Walk::fixed(text_w, rect.size.y)
|
|
.with_abs_pos(dvec2(rect.pos.x + self.row_pad, rect.pos.y)),
|
|
Layout::flow_right(),
|
|
);
|
|
let size = self.draw_label.text_style.font_size as f64;
|
|
let baseline = ink_y(rect, size);
|
|
let mut pen = rect.pos.x + self.row_pad;
|
|
self.draw_label.active = if active { 1.0 } else { 0.0 };
|
|
for (marked, run) in mark_runs(label, marks) {
|
|
self.draw_label.matched = if marked { 1.0 } else { 0.0 };
|
|
self.draw_label.draw_abs(cx, dvec2(pen, baseline), &run);
|
|
pen += run_width(&self.draw_label, cx, &run);
|
|
}
|
|
self.draw_label.matched = 0.0;
|
|
self.draw_label.active = 0.0;
|
|
cx.end_turtle();
|
|
|
|
self.rows.push((at, rect));
|
|
cy += self.row_height;
|
|
}
|
|
}
|
|
}
|
|
|
|
if !self.hint_text.is_empty() {
|
|
let rect = Rect { pos: dvec2(left, cy), size: dvec2(width, self.hint_height) };
|
|
let size = self.draw_meta.text_style.font_size as f64;
|
|
let text = self.hint_text.clone();
|
|
self.draw_meta
|
|
.draw_abs(cx, dvec2(left + self.row_pad, ink_y(rect, size)), &text);
|
|
}
|
|
|
|
self.draw_panel.end(cx);
|
|
self.panel_rect = Rect { pos: dvec2(x, y), size: dvec2(panel_w, panel_h) };
|
|
}
|
|
}
|
|
|
|
impl Widget for CommandPalette {
|
|
fn script_call(
|
|
&mut self,
|
|
vm: &mut ScriptVm,
|
|
method: LiveId,
|
|
_args: ScriptValue,
|
|
) -> ScriptAsyncResult {
|
|
if method == live_id!(open) {
|
|
vm.with_cx_mut(|cx| self.open(cx));
|
|
return ScriptAsyncResult::Return(NIL);
|
|
}
|
|
if method == live_id!(close) {
|
|
vm.with_cx_mut(|cx| self.close(cx));
|
|
return ScriptAsyncResult::Return(NIL);
|
|
}
|
|
if method == live_id!(toggle) {
|
|
vm.with_cx_mut(|cx| self.toggle(cx));
|
|
return ScriptAsyncResult::Return(NIL);
|
|
}
|
|
ScriptAsyncResult::MethodNotFound
|
|
}
|
|
|
|
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
|
|
if !self.shown {
|
|
return;
|
|
}
|
|
|
|
// The keys the list owns, taken before the field can eat them. Gated
|
|
// on the field holding focus, so a palette that is up but not typed
|
|
// into does not swallow the arrows of whatever does.
|
|
if let Event::KeyDown(ke) = event {
|
|
if self.search.key_focus(cx) && self.handle_nav_key(cx, ke) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
for action in cx.capture_actions(|cx| self.search.handle_event(cx, event, scope)) {
|
|
match action.as_widget_action().cast() {
|
|
TextInputAction::Changed(text) => self.set_query(cx, &text),
|
|
TextInputAction::Escaped => {
|
|
self.dismiss(cx);
|
|
return;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Everything over the scrim belongs to the palette. Asking for the
|
|
// hit is what consumes it, and consuming it is what keeps the press
|
|
// away from whatever is drawn underneath.
|
|
match event.hits(cx, self.draw_scrim.area()) {
|
|
Hit::FingerHoverIn(fe) | Hit::FingerHoverOver(fe) => {
|
|
let at = self
|
|
.rows
|
|
.iter()
|
|
.find(|(_, rect)| rect.contains(fe.abs))
|
|
.map(|(at, _)| *at);
|
|
if at != self.hover {
|
|
self.hover = at;
|
|
cx.set_cursor(if at.is_some() {
|
|
MouseCursor::Hand
|
|
} else {
|
|
MouseCursor::Default
|
|
});
|
|
self.redraw_overlay(cx);
|
|
}
|
|
}
|
|
Hit::FingerHoverOut(_) => {
|
|
if self.hover.take().is_some() {
|
|
self.redraw_overlay(cx);
|
|
}
|
|
}
|
|
Hit::FingerUp(fe) if fe.is_primary_hit() && !fe.cancelled => {
|
|
let row = self
|
|
.rows
|
|
.iter()
|
|
.find(|(_, rect)| rect.contains(fe.abs))
|
|
.map(|(at, _)| *at);
|
|
match row {
|
|
// A press runs the row it landed on, not the row the
|
|
// keyboard is holding: the finger is the instruction.
|
|
Some(at) => self.run(cx, at),
|
|
None if !self.panel_rect.contains(fe.abs) => self.dismiss(cx),
|
|
None => {}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, _walk: Walk) -> DrawStep {
|
|
if self.draw_list.is_none() {
|
|
self.draw_list = Some(DrawList2d::new(cx));
|
|
}
|
|
self.draw_list.as_mut().unwrap().begin_overlay_reuse(cx);
|
|
let pass = cx.current_pass_size();
|
|
cx.begin_root_turtle(pass, Layout::flow_overlay());
|
|
if self.shown && self.visible {
|
|
// The scrim is begun around the panel so its area is the whole
|
|
// pass, which is what the hit test above tests against.
|
|
self.draw_scrim.begin(cx, Walk::fill(), Layout::flow_overlay());
|
|
self.draw_body(cx, scope, pass);
|
|
self.draw_scrim.end(cx);
|
|
} else {
|
|
self.rows.clear();
|
|
self.panel_rect = Rect { pos: dvec2(0.0, 0.0), size: dvec2(0.0, 0.0) };
|
|
}
|
|
cx.end_pass_sized_turtle();
|
|
self.draw_list.as_mut().unwrap().end(cx);
|
|
|
|
if self.shown && self.visible {
|
|
cx.block_scrolling_except_within(self.draw_panel.area());
|
|
if self.want_focus {
|
|
self.want_focus = false;
|
|
self.search.take_key_focus(cx.cx);
|
|
}
|
|
}
|
|
DrawStep::done()
|
|
}
|
|
|
|
/// What is in the search box.
|
|
fn text(&self) -> String {
|
|
self.query.clone()
|
|
}
|
|
|
|
fn set_text(&mut self, cx: &mut Cx, v: &str) {
|
|
self.set_query(cx, v);
|
|
}
|
|
|
|
/// The command the cursor is on, which is what the palette is being asked
|
|
/// for.
|
|
fn snapshot_value(&self, _cx: &Cx) -> Option<String> {
|
|
Some(self.highlighted().unwrap_or_default())
|
|
}
|
|
}
|
|
|
|
impl CommandPaletteRef {
|
|
pub fn open(&self, cx: &mut Cx) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.open(cx);
|
|
}
|
|
}
|
|
|
|
pub fn close(&self, cx: &mut Cx) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.close(cx);
|
|
}
|
|
}
|
|
|
|
pub fn toggle(&self, cx: &mut Cx) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.toggle(cx);
|
|
}
|
|
}
|
|
|
|
/// Ask rather than track: the palette closes for reasons a host never
|
|
/// hears about.
|
|
pub fn is_open(&self) -> bool {
|
|
self.borrow().map(|inner| inner.is_open()).unwrap_or(false)
|
|
}
|
|
|
|
pub fn set_commands(&self, cx: &mut Cx, commands: Vec<String>) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.set_commands(cx, commands);
|
|
}
|
|
}
|
|
|
|
pub fn set_query(&self, cx: &mut Cx, query: &str) {
|
|
if let Some(mut inner) = self.borrow_mut() {
|
|
inner.set_query(cx, query);
|
|
}
|
|
}
|
|
|
|
pub fn matches(&self) -> usize {
|
|
self.borrow().map(|inner| inner.matches()).unwrap_or(0)
|
|
}
|
|
|
|
pub fn highlighted(&self) -> Option<String> {
|
|
self.borrow().and_then(|inner| inner.highlighted())
|
|
}
|
|
|
|
/// The command chosen this pass: its ordinal among the command rows as
|
|
/// written, and its label.
|
|
pub fn ran(&self, actions: &Actions) -> Option<(usize, String)> {
|
|
let action = actions.find_widget_action(self.widget_uid())?;
|
|
match action.cast::<CommandPaletteAction>() {
|
|
CommandPaletteAction::Ran { index, label, .. } => Some((index, label)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The chord of the command chosen this pass, as it was written.
|
|
pub fn ran_shortcut(&self, actions: &Actions) -> Option<String> {
|
|
let action = actions.find_widget_action(self.widget_uid())?;
|
|
match action.cast::<CommandPaletteAction>() {
|
|
CommandPaletteAction::Ran { shortcut, .. } => Some(shortcut),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Closed by the person, with nothing run.
|
|
pub fn dismissed(&self, actions: &Actions) -> bool {
|
|
actions
|
|
.find_widget_action(self.widget_uid())
|
|
.map(|action| matches!(action.cast(), CommandPaletteAction::Dismissed))
|
|
.unwrap_or(false)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn lines(rows: &[&str]) -> Vec<String> {
|
|
rows.iter().map(|row| row.to_string()).collect()
|
|
}
|
|
|
|
fn table(rows: &[&str]) -> Vec<HelpEntry> {
|
|
parse_help_entries(&lines(rows))
|
|
}
|
|
|
|
#[test]
|
|
fn the_letters_may_be_anywhere_as_long_as_they_are_in_order() {
|
|
let hit = fuzzy_match("Toggle Full Screen", "tfs").expect("a subsequence");
|
|
assert_eq!(hit.marks, [0, 7, 12]);
|
|
assert!(fuzzy_match("Toggle Full Screen", "toggle").is_some());
|
|
// Case is not part of it, on either side.
|
|
assert!(fuzzy_match("Toggle Full Screen", "TFS").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn out_of_order_is_not_a_match_and_neither_is_a_letter_that_is_not_there() {
|
|
assert!(fuzzy_match("Frame All", "zzz").is_none());
|
|
assert!(fuzzy_match("Frame All", "af").is_none(), "the letters were in the wrong order");
|
|
assert!(fuzzy_match("Copy", "copies").is_none(), "longer than the label");
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_query_matches_everything_at_nothing() {
|
|
let hit = fuzzy_match("Frame All", "").expect("an empty query matches");
|
|
assert_eq!(hit.score, 0);
|
|
assert!(hit.marks.is_empty());
|
|
assert_eq!(fuzzy_match("Frame All", " ").expect("blank is empty").score, 0);
|
|
}
|
|
|
|
/// The letters somebody types are usually the initials, so the scoring
|
|
/// has to prefer word starts over the first characters that happen to
|
|
/// fit. "fa" is the F of Frame and the A of All, not the a of Frame.
|
|
#[test]
|
|
fn the_marks_land_on_the_word_starts_rather_than_the_earliest_fit() {
|
|
let hit = fuzzy_match("Frame All", "fa").expect("a match");
|
|
assert_eq!(hit.marks, [0, 6]);
|
|
assert_eq!(
|
|
mark_runs("Frame All", &hit.marks),
|
|
vec![
|
|
(true, "F".to_string()),
|
|
(false, "rame ".to_string()),
|
|
(true, "A".to_string()),
|
|
(false, "ll".to_string()),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_match_near_the_front_beats_the_same_match_further_in() {
|
|
let early = fuzzy_match("Open Project", "op").expect("a match").score;
|
|
let late = fuzzy_match("Stop Playing", "op").expect("a match").score;
|
|
assert!(early > late, "{early} should beat {late}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_run_of_letters_beats_the_same_letters_scattered() {
|
|
let together = fuzzy_match("Reload", "rel").expect("a match").score;
|
|
let apart = fuzzy_match("Rotate Element", "rel").expect("a match").score;
|
|
assert!(together > apart, "{together} should beat {apart}");
|
|
}
|
|
|
|
#[test]
|
|
fn camel_case_counts_as_the_words_it_is() {
|
|
// No spaces to go on, so the case change is the only word boundary
|
|
// there is; without it "fa" here would mark the a of Frame.
|
|
let hit = fuzzy_match("FrameAll", "fa").expect("a match");
|
|
assert_eq!(hit.marks, [0, 5]);
|
|
}
|
|
|
|
#[test]
|
|
fn with_no_query_the_table_reads_exactly_as_it_was_written() {
|
|
let entries = table(&[
|
|
"# Files",
|
|
"Reload config = ctrl+r",
|
|
"# Editing",
|
|
"Copy = ctrl+c",
|
|
"Cut = ctrl+x",
|
|
]);
|
|
let out = rank_commands(&entries, "");
|
|
assert_eq!(out[0], PaletteLine::Heading("Files".to_string()));
|
|
assert_eq!(out[2], PaletteLine::Heading("Editing".to_string()));
|
|
assert_eq!(out.len(), 5);
|
|
}
|
|
|
|
/// The group holding the best command comes first, so the cursor's home
|
|
/// — the first command of the first group — is the best match in the
|
|
/// whole list, and the headings still survive the ranking.
|
|
#[test]
|
|
fn the_group_with_the_best_command_comes_first() {
|
|
let entries = table(&[
|
|
"# Files",
|
|
"Reload config = ctrl+r",
|
|
"# Editing",
|
|
"Copy = ctrl+c",
|
|
"Cut = ctrl+x",
|
|
]);
|
|
let out = rank_commands(&entries, "co");
|
|
assert_eq!(out[0], PaletteLine::Heading("Editing".to_string()));
|
|
match &out[1] {
|
|
PaletteLine::Command { label, .. } => assert_eq!(label, "Copy"),
|
|
other => panic!("expected the best command, got {other:?}"),
|
|
}
|
|
assert_eq!(out[2], PaletteLine::Heading("Files".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn inside_a_group_the_better_match_rises() {
|
|
let entries = table(&["# Editing", "Select all = ctrl+a", "Copy = ctrl+c"]);
|
|
let out = rank_commands(&entries, "c");
|
|
match &out[1] {
|
|
PaletteLine::Command { label, .. } => {
|
|
assert_eq!(label, "Copy", "written second, ranked first")
|
|
}
|
|
other => panic!("expected a command, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_group_that_lost_every_command_loses_its_heading_too() {
|
|
let entries = table(&["# Files", "Reload = ctrl+r", "# Editing", "Copy = ctrl+c"]);
|
|
let out = rank_commands(&entries, "copy");
|
|
assert_eq!(
|
|
out,
|
|
vec![
|
|
PaletteLine::Heading("Editing".to_string()),
|
|
PaletteLine::Command {
|
|
index: 1,
|
|
label: "Copy".to_string(),
|
|
shortcut: "ctrl+c".to_string(),
|
|
marks: vec![0, 1, 2, 3],
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
/// The ordinal a row reports is its position among the command rows as
|
|
/// WRITTEN — a host indexes its own array by it, and it must not move
|
|
/// when the ranking does.
|
|
#[test]
|
|
fn the_ordinal_is_the_written_position_not_the_ranked_one() {
|
|
let entries = table(&["# Editing", "Select all = ctrl+a", "Copy = ctrl+c"]);
|
|
let out = rank_commands(&entries, "c");
|
|
let ordinals: Vec<usize> = out
|
|
.iter()
|
|
.filter_map(|line| match line {
|
|
PaletteLine::Command { index, .. } => Some(*index),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(ordinals, [1, 0]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_command_written_before_any_heading_still_appears() {
|
|
let entries = table(&["Quit = ctrl+q", "# Editing", "Copy = ctrl+c"]);
|
|
let out = rank_commands(&entries, "quit");
|
|
assert_eq!(out.len(), 1, "and brings no heading with it");
|
|
match &out[0] {
|
|
PaletteLine::Command { label, .. } => assert_eq!(label, "Quit"),
|
|
other => panic!("expected a command, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn runs_are_one_call_to_the_text_layer_each() {
|
|
assert_eq!(mark_runs("Copy", &[]), vec![(false, "Copy".to_string())]);
|
|
assert_eq!(
|
|
mark_runs("Copy", &[0, 1, 2, 3]),
|
|
vec![(true, "Copy".to_string())],
|
|
"neighbouring marks are one run, not four"
|
|
);
|
|
assert!(mark_runs("", &[]).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn the_window_moves_only_far_enough_to_hold_the_cursor() {
|
|
assert_eq!(palette_window_top(20, Some(15), 0, 12), 4);
|
|
assert_eq!(palette_window_top(20, Some(2), 4, 12), 2);
|
|
assert_eq!(palette_window_top(20, Some(6), 4, 12), 4, "already inside, left alone");
|
|
}
|
|
|
|
#[test]
|
|
fn the_window_never_scrolls_past_the_last_line() {
|
|
assert_eq!(palette_window_top(20, Some(19), 0, 12), 8);
|
|
assert_eq!(palette_window_top(3, Some(0), 9, 12), 0, "a short list starts at the top");
|
|
assert_eq!(palette_window_top(0, None, 5, 12), 0);
|
|
}
|
|
}
|