Add the UI foundation: vim keymap, widgets and the component contract
- keymap: every key sequence in vim notation (gg, yy, <C-a>, <Space>s), with counts (5j), per-screen overrides and which-key hints, all from the same tables. - Component: the one trait for screens and popups. They only look at the data through a Context and answer with Actions. - Action: everything a screen can ask the app to do. - widgets: big digits, buttons, a day timeline, a heatmap, a text field. - ui/mod.rs: tabs, status line and the which-key popup; terminal.rs sets the terminal up (and restores it after a crash). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
eff7834838
commit
7c8a02275e
13 files changed
+1588
No files matched your search
@@ -0,0 +1,502 @@
|
||||
//! Vim-style keys: every key sequence skyshift understands, in one place.
|
||||
//!
|
||||
//! Keys are written in vim notation:
|
||||
//! - letters and symbols as they are: `j`, `G`, `gt`, `yy`, `:`, `?`
|
||||
//! - special keys in angle brackets: `<CR>` (Enter), `<Esc>`, `<Tab>`,
|
||||
//! `<S-Tab>`, `<BS>`, `<Space>`, `<Up>`, `<Down>`, `<Left>`, `<Right>`,
|
||||
//! `<lt>` (the `<` key itself)
|
||||
//! - Ctrl with a letter: `<C-a>`
|
||||
//!
|
||||
//! Screens never see raw keys: [`Keys`] turns key presses into [`Command`]s
|
||||
//! using the tables below, with vim-style counts (`5j`) and multi-key
|
||||
//! sequences (`gg`, `yy`). To change a key, change it in a table: the help
|
||||
//! screen and the which-key hints are generated from the same tables.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use super::Tab;
|
||||
use crate::export::timesheet::CopyPreset;
|
||||
|
||||
/// What a key sequence asks for. Each screen decides what it means there:
|
||||
/// `Down` moves through days on the Days screen and through settings on the
|
||||
/// Settings screen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
// Moving around
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
HalfPageUp,
|
||||
HalfPageDown,
|
||||
NextWeek,
|
||||
PreviousWeek,
|
||||
NextScreen,
|
||||
PreviousScreen,
|
||||
GoTo(Tab),
|
||||
// The shift
|
||||
Start,
|
||||
Pause,
|
||||
Toggle,
|
||||
EndDay,
|
||||
StartAt,
|
||||
PauseAt,
|
||||
EndDayAt,
|
||||
StartEarlier,
|
||||
StartLater,
|
||||
Snooze,
|
||||
// Recorded work
|
||||
Edit,
|
||||
AddAfter,
|
||||
AddBefore,
|
||||
Delete,
|
||||
Undo,
|
||||
Redo,
|
||||
// Copy and export
|
||||
Copy(CopyTarget, Option<CopyPreset>),
|
||||
CopyMenu,
|
||||
Export,
|
||||
// Insights and Settings
|
||||
ShorterPeriod,
|
||||
LongerPeriod,
|
||||
ToggleExpert,
|
||||
ResetSetting,
|
||||
// Popups and text fields (normal mode)
|
||||
Confirm,
|
||||
Close,
|
||||
Insert,
|
||||
InsertAtStart,
|
||||
Retype,
|
||||
// The app
|
||||
OpenCommandLine,
|
||||
Help,
|
||||
ToggleWhichKey,
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// What a copy command copies.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CopyTarget {
|
||||
Day,
|
||||
Week,
|
||||
}
|
||||
|
||||
/// One key sequence and what it does.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Binding {
|
||||
pub keys: &'static str,
|
||||
pub command: Command,
|
||||
pub help: &'static str,
|
||||
}
|
||||
|
||||
const fn bind(keys: &'static str, command: Command, help: &'static str) -> Binding {
|
||||
Binding { keys, command, help }
|
||||
}
|
||||
|
||||
use Command as C;
|
||||
|
||||
/// Keys that work on every screen (when no popup is open).
|
||||
pub const GLOBAL: &[Binding] = &[
|
||||
bind("s", C::Start, "start / resume work"),
|
||||
bind("p", C::Pause, "pause (take a break)"),
|
||||
bind("e", C::EndDay, "end the work day"),
|
||||
bind("<C-x>", C::StartEarlier, "session started earlier (-step)"),
|
||||
bind("<C-a>", C::StartLater, "session started later (+step)"),
|
||||
bind("u", C::Undo, "undo"),
|
||||
bind("<C-r>", C::Redo, "redo"),
|
||||
bind("yy", C::Copy(CopyTarget::Day, None), "copy the day"),
|
||||
bind("yw", C::Copy(CopyTarget::Week, None), "copy the week (a line per day)"),
|
||||
bind("yh", C::Copy(CopyTarget::Day, Some(CopyPreset::TotalClock)), "copy total as H:MM"),
|
||||
bind("yd", C::Copy(CopyTarget::Day, Some(CopyPreset::TotalDecimal)), "copy total as decimal hours"),
|
||||
bind("ys", C::Copy(CopyTarget::Day, Some(CopyPreset::Segments)), "copy the segments"),
|
||||
bind("yi", C::Copy(CopyTarget::Day, Some(CopyPreset::InOutBreak)), "copy in / out / break"),
|
||||
bind("yc", C::Copy(CopyTarget::Day, Some(CopyPreset::Custom)), "copy with your template"),
|
||||
bind("gt", C::NextScreen, "next screen"),
|
||||
bind("gT", C::PreviousScreen, "previous screen"),
|
||||
bind("gh", C::GoTo(Tab::Tracker), "go to Tracker"),
|
||||
bind("gd", C::GoTo(Tab::Days), "go to Days"),
|
||||
bind("gi", C::GoTo(Tab::Insights), "go to Insights"),
|
||||
bind("gs", C::GoTo(Tab::Settings), "go to Settings"),
|
||||
bind("<Tab>", C::NextScreen, "next screen"),
|
||||
bind("<S-Tab>", C::PreviousScreen, "previous screen"),
|
||||
bind("zz", C::Snooze, "snooze the break reminder"),
|
||||
bind("<Space><Space>", C::Toggle, "start / pause"),
|
||||
bind("<Space>s", C::StartAt, "start at an earlier time..."),
|
||||
bind("<Space>p", C::PauseAt, "pause at an earlier time..."),
|
||||
bind("<Space>e", C::EndDayAt, "end the day at an earlier time..."),
|
||||
bind("<Space>c", C::CopyMenu, "copy with a format..."),
|
||||
bind("<Space>x", C::Export, "export a report..."),
|
||||
bind("<Space>w", C::ToggleWhichKey, "toggle key hints"),
|
||||
bind("<Space>?", C::Help, "all keys"),
|
||||
bind(":", C::OpenCommandLine, "command line"),
|
||||
bind("?", C::Help, "all keys"),
|
||||
bind("<Esc>", C::Close, "cancel"),
|
||||
bind("q", C::Quit, "quit (a running shift keeps running)"),
|
||||
bind("ZZ", C::Quit, "quit"),
|
||||
];
|
||||
|
||||
/// Moving through a list (Days, Settings).
|
||||
pub const LIST: &[Binding] = &[
|
||||
bind("j", C::Down, "down"),
|
||||
bind("<Down>", C::Down, "down"),
|
||||
bind("k", C::Up, "up"),
|
||||
bind("<Up>", C::Up, "up"),
|
||||
bind("gg", C::Top, "go to the top"),
|
||||
bind("G", C::Bottom, "go to the bottom"),
|
||||
bind("<C-d>", C::HalfPageDown, "half a page down"),
|
||||
bind("<C-u>", C::HalfPageUp, "half a page up"),
|
||||
];
|
||||
|
||||
pub const TRACKER: &[Binding] = &[
|
||||
bind("<CR>", C::Edit, "fix the session in progress"),
|
||||
bind("i", C::Edit, "fix the session in progress"),
|
||||
bind("o", C::AddAfter, "add forgotten work to today"),
|
||||
];
|
||||
|
||||
pub const DAYS: &[Binding] = &[
|
||||
bind("h", C::Left, "focus the day list"),
|
||||
bind("<Left>", C::Left, "focus the day list"),
|
||||
bind("l", C::Right, "focus the segments"),
|
||||
bind("<Right>", C::Right, "focus the segments"),
|
||||
bind("w", C::NextWeek, "down to the next week"),
|
||||
bind("b", C::PreviousWeek, "up to the previous week"),
|
||||
bind("<CR>", C::Edit, "edit the segment"),
|
||||
bind("i", C::Edit, "edit the segment"),
|
||||
bind("o", C::AddAfter, "add a segment"),
|
||||
bind("O", C::AddBefore, "add a segment before"),
|
||||
bind("dd", C::Delete, "delete the segment"),
|
||||
bind("x", C::Delete, "delete the segment"),
|
||||
];
|
||||
|
||||
pub const INSIGHTS: &[Binding] = &[
|
||||
bind("h", C::Left, "previous view"),
|
||||
bind("<Left>", C::Left, "previous view"),
|
||||
bind("l", C::Right, "next view"),
|
||||
bind("<Right>", C::Right, "next view"),
|
||||
bind("<lt>", C::ShorterPeriod, "shorter period"),
|
||||
bind(">", C::LongerPeriod, "longer period"),
|
||||
];
|
||||
|
||||
pub const SETTINGS: &[Binding] = &[
|
||||
bind("h", C::Left, "previous value"),
|
||||
bind("<Left>", C::Left, "previous value"),
|
||||
bind("l", C::Right, "next value"),
|
||||
bind("<Right>", C::Right, "next value"),
|
||||
bind("<CR>", C::Edit, "edit the value"),
|
||||
bind("i", C::Edit, "edit the value"),
|
||||
bind("a", C::Edit, "edit the value"),
|
||||
bind("cc", C::Retype, "retype the value"),
|
||||
bind("x", C::ResetSetting, "reset to the default"),
|
||||
bind("za", C::ToggleExpert, "show / hide expert settings"),
|
||||
];
|
||||
|
||||
/// Normal mode inside popups (forms and menus).
|
||||
pub const POPUP: &[Binding] = &[
|
||||
bind("j", C::Down, "next field"),
|
||||
bind("<Down>", C::Down, "next field"),
|
||||
bind("<Tab>", C::Down, "next field"),
|
||||
bind("k", C::Up, "previous field"),
|
||||
bind("<Up>", C::Up, "previous field"),
|
||||
bind("<S-Tab>", C::Up, "previous field"),
|
||||
bind("gg", C::Top, "first"),
|
||||
bind("G", C::Bottom, "last"),
|
||||
bind("h", C::Left, "previous choice"),
|
||||
bind("<Left>", C::Left, "previous choice"),
|
||||
bind("l", C::Right, "next choice"),
|
||||
bind("<Right>", C::Right, "next choice"),
|
||||
bind("i", C::Insert, "type"),
|
||||
bind("a", C::Insert, "type"),
|
||||
bind("A", C::Insert, "type"),
|
||||
bind("I", C::InsertAtStart, "type at the start"),
|
||||
bind("cc", C::Retype, "retype the field"),
|
||||
bind("S", C::Retype, "retype the field"),
|
||||
bind("<CR>", C::Confirm, "confirm"),
|
||||
bind("<Esc>", C::Close, "cancel"),
|
||||
bind("q", C::Close, "cancel"),
|
||||
];
|
||||
|
||||
/// Names of the key groups, shown as titles in the which-key popup.
|
||||
pub const GROUPS: &[(&str, &str)] = &[
|
||||
("g", "go to"),
|
||||
("y", "copy (yank)"),
|
||||
("d", "delete"),
|
||||
("c", "change"),
|
||||
("z", "reminders & view"),
|
||||
("Z", "quit"),
|
||||
("<Space>", "actions"),
|
||||
];
|
||||
|
||||
// ----- Keys ----------------------------------------------------------------
|
||||
|
||||
/// One key press: a key, and whether Ctrl was held. Shift is part of the
|
||||
/// character itself (`G` is Shift+g).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Key {
|
||||
code: KeyCode,
|
||||
ctrl: bool,
|
||||
}
|
||||
|
||||
impl Key {
|
||||
pub fn from_event(event: KeyEvent) -> Key {
|
||||
let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
|
||||
let code = match event.code {
|
||||
KeyCode::Char(c) if ctrl => KeyCode::Char(c.to_ascii_lowercase()),
|
||||
other => other,
|
||||
};
|
||||
Key { code, ctrl }
|
||||
}
|
||||
|
||||
fn char(c: char) -> Key {
|
||||
Key { code: KeyCode::Char(c), ctrl: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads vim key notation: `"gt"` is g then t, `"<C-a>"` is Ctrl+a,
|
||||
/// `"<Space>s"` is Space then s.
|
||||
pub fn parse(notation: &str) -> Vec<Key> {
|
||||
let mut keys = Vec::new();
|
||||
let mut rest = notation;
|
||||
while let Some(c) = rest.chars().next() {
|
||||
let special = if c == '<' { rest.find('>').map(|end| &rest[1..end]) } else { None };
|
||||
match special.and_then(special_key) {
|
||||
Some(key) => {
|
||||
keys.push(key);
|
||||
rest = &rest[special.map_or(0, str::len) + 2..];
|
||||
}
|
||||
None => {
|
||||
keys.push(Key::char(c));
|
||||
rest = &rest[c.len_utf8()..];
|
||||
}
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
/// A name between angle brackets, like `CR` or `C-a`.
|
||||
fn special_key(name: &str) -> Option<Key> {
|
||||
let code = match name {
|
||||
"CR" | "Enter" => KeyCode::Enter,
|
||||
"Esc" => KeyCode::Esc,
|
||||
"Tab" => KeyCode::Tab,
|
||||
"S-Tab" => KeyCode::BackTab,
|
||||
"BS" => KeyCode::Backspace,
|
||||
"Del" => KeyCode::Delete,
|
||||
"Space" => KeyCode::Char(' '),
|
||||
"Up" => KeyCode::Up,
|
||||
"Down" => KeyCode::Down,
|
||||
"Left" => KeyCode::Left,
|
||||
"Right" => KeyCode::Right,
|
||||
"lt" => KeyCode::Char('<'),
|
||||
_ => {
|
||||
let letter = name.strip_prefix("C-")?;
|
||||
let mut chars = letter.chars();
|
||||
let (Some(c), None) = (chars.next(), chars.next()) else { return None };
|
||||
return Some(Key { code: KeyCode::Char(c.to_ascii_lowercase()), ctrl: true });
|
||||
}
|
||||
};
|
||||
Some(Key { code, ctrl: false })
|
||||
}
|
||||
|
||||
/// Writes one key back in vim notation (for the which-key popup).
|
||||
fn notation(key: Key) -> String {
|
||||
match (key.code, key.ctrl) {
|
||||
(KeyCode::Char(c), true) => format!("<C-{c}>"),
|
||||
(KeyCode::Char(' '), false) => "<Space>".into(),
|
||||
(KeyCode::Char('<'), false) => "<".into(),
|
||||
(KeyCode::Char(c), false) => c.to_string(),
|
||||
(KeyCode::Enter, _) => "<CR>".into(),
|
||||
(KeyCode::Esc, _) => "<Esc>".into(),
|
||||
(KeyCode::Tab, _) => "<Tab>".into(),
|
||||
(KeyCode::BackTab, _) => "<S-Tab>".into(),
|
||||
(code, _) => format!("<{code:?}>"),
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Turning key presses into commands -----------------------------------
|
||||
|
||||
/// What a key press led to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
/// A whole sequence matched. `count` is the number typed before it, if any.
|
||||
Run { command: Command, count: Option<usize> },
|
||||
/// The keys so far are the start of a sequence: waiting for the next key.
|
||||
Waiting,
|
||||
/// Esc cancelled a sequence in progress.
|
||||
Cancelled,
|
||||
/// No sequence starts like this (the keys were dropped).
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
/// Remembers the keys of a sequence in progress, like vim does after `g`.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Keys {
|
||||
pending: Vec<Key>,
|
||||
count: Option<usize>,
|
||||
/// When the sequence in progress started (for the which-key delay).
|
||||
since: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Keys {
|
||||
/// Feeds one key press. `tables` are searched in order: an earlier table
|
||||
/// wins when two define the same keys (screens override global keys).
|
||||
pub fn press(&mut self, key: Key, tables: &[&[Binding]]) -> Outcome {
|
||||
if key.code == KeyCode::Esc && self.is_pending() {
|
||||
self.reset();
|
||||
return Outcome::Cancelled;
|
||||
}
|
||||
// A count before a command, like the 5 in `5j` (0 only continues one).
|
||||
if self.pending.is_empty()
|
||||
&& !key.ctrl
|
||||
&& let KeyCode::Char(c @ '0'..='9') = key.code
|
||||
&& (c != '0' || self.count.is_some())
|
||||
{
|
||||
let digit = c as usize - '0' as usize;
|
||||
self.count = Some((self.count.unwrap_or(0) * 10 + digit).min(9999));
|
||||
return Outcome::Waiting;
|
||||
}
|
||||
|
||||
self.pending.push(key);
|
||||
self.since.get_or_insert_with(Instant::now);
|
||||
let candidates = self.candidates(tables);
|
||||
if let Some(exact) = candidates.iter().find(|(keys, _)| *keys == self.pending) {
|
||||
let outcome = Outcome::Run { command: exact.1.command, count: self.count };
|
||||
self.reset();
|
||||
return outcome;
|
||||
}
|
||||
if candidates.is_empty() {
|
||||
let typed: String = self.pending.iter().map(|key| notation(*key)).collect();
|
||||
self.reset();
|
||||
return Outcome::Unknown(typed);
|
||||
}
|
||||
Outcome::Waiting
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
*self = Keys::default();
|
||||
}
|
||||
|
||||
/// A sequence (or a count) is in progress.
|
||||
pub fn is_pending(&self) -> bool {
|
||||
!self.pending.is_empty() || self.count.is_some()
|
||||
}
|
||||
|
||||
/// When the sequence in progress started (not counting a count).
|
||||
pub fn pending_since(&self) -> Option<Instant> {
|
||||
self.since
|
||||
}
|
||||
|
||||
/// The keys typed so far, like vim's "showcmd": `3g`.
|
||||
pub fn typed(&self) -> String {
|
||||
let count = self.count.map(|count| count.to_string()).unwrap_or_default();
|
||||
count + &self.pending.iter().map(|key| notation(*key)).collect::<String>()
|
||||
}
|
||||
|
||||
/// The title of the which-key popup: the group the typed keys open.
|
||||
pub fn group_name(&self) -> Option<&'static str> {
|
||||
let typed: String = self.pending.iter().map(|key| notation(*key)).collect();
|
||||
GROUPS.iter().find(|(keys, _)| *keys == typed).map(|(_, name)| *name)
|
||||
}
|
||||
|
||||
/// Every key that can come next, with what it does, for the which-key
|
||||
/// popup. A key that starts a longer sequence shows its group name.
|
||||
pub fn hints(&self, tables: &[&[Binding]]) -> Vec<(String, String)> {
|
||||
let mut hints: Vec<(String, String)> = Vec::new();
|
||||
if self.pending.is_empty() {
|
||||
return hints;
|
||||
}
|
||||
for (keys, binding) in self.candidates(tables) {
|
||||
let next = notation(keys[self.pending.len()]);
|
||||
if hints.iter().any(|(key, _)| *key == next) {
|
||||
continue;
|
||||
}
|
||||
let label = if keys.len() == self.pending.len() + 1 {
|
||||
binding.help.to_string()
|
||||
} else {
|
||||
let group: String = keys[..=self.pending.len()].iter().map(|key| notation(*key)).collect();
|
||||
let name = GROUPS.iter().find(|(keys, _)| *keys == group).map_or("more", |(_, name)| name);
|
||||
format!("+{name}")
|
||||
};
|
||||
hints.push((next, label));
|
||||
}
|
||||
hints
|
||||
}
|
||||
|
||||
/// Bindings that start with the keys typed so far, parsed. Bindings
|
||||
/// hidden by an earlier table with the same keys are skipped.
|
||||
fn candidates(&self, tables: &[&[Binding]]) -> Vec<(Vec<Key>, Binding)> {
|
||||
let mut seen: Vec<Vec<Key>> = Vec::new();
|
||||
let mut found = Vec::new();
|
||||
for binding in tables.iter().flat_map(|table| table.iter()) {
|
||||
let keys = parse(binding.keys);
|
||||
if seen.contains(&keys) {
|
||||
continue;
|
||||
}
|
||||
if keys.starts_with(&self.pending) {
|
||||
found.push((keys.clone(), *binding));
|
||||
}
|
||||
seen.push(keys);
|
||||
}
|
||||
found
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn press_all(keys: &mut Keys, notation: &str, tables: &[&[Binding]]) -> Outcome {
|
||||
let mut last = Outcome::Waiting;
|
||||
for key in parse(notation) {
|
||||
last = keys.press(key, tables);
|
||||
}
|
||||
last
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notation_round_trips() {
|
||||
for text in ["gt", "<C-a>", "<Space>s", "<CR>", "<S-Tab>", "ZZ", "<lt>"] {
|
||||
let keys = parse(text);
|
||||
let back: String = keys.iter().map(|key| notation(*key)).collect();
|
||||
assert_eq!(parse(&back), keys, "{text}");
|
||||
}
|
||||
assert_eq!(parse("<C-a>"), vec![Key { code: KeyCode::Char('a'), ctrl: true }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequences_counts_and_overrides() {
|
||||
let mut keys = Keys::default();
|
||||
let tables: &[&[Binding]] = &[DAYS, LIST, GLOBAL];
|
||||
assert_eq!(press_all(&mut keys, "g", tables), Outcome::Waiting);
|
||||
assert!(keys.hints(tables).iter().any(|(key, _)| key == "t"));
|
||||
assert_eq!(press_all(&mut keys, "g", tables), Outcome::Run { command: C::Top, count: None });
|
||||
assert_eq!(press_all(&mut keys, "5j", tables), Outcome::Run { command: C::Down, count: Some(5) });
|
||||
// On the Days screen, `x` deletes; `gx` means nothing.
|
||||
assert_eq!(press_all(&mut keys, "gx", tables), Outcome::Unknown("gx".into()));
|
||||
assert_eq!(press_all(&mut keys, "y<Esc>", tables), Outcome::Cancelled);
|
||||
}
|
||||
|
||||
/// A binding must never be the start of a longer one in the same place,
|
||||
/// or the longer one could never be typed.
|
||||
#[test]
|
||||
fn no_binding_hides_another() {
|
||||
let places: [&[&[Binding]]; 5] = [
|
||||
&[TRACKER, GLOBAL],
|
||||
&[DAYS, LIST, GLOBAL],
|
||||
&[INSIGHTS, GLOBAL],
|
||||
&[SETTINGS, LIST, GLOBAL],
|
||||
&[POPUP],
|
||||
];
|
||||
for tables in places {
|
||||
let all: Vec<Vec<Key>> = tables.iter().flat_map(|t| t.iter()).map(|b| parse(b.keys)).collect();
|
||||
for a in &all {
|
||||
for b in &all {
|
||||
assert!(!(b.len() > a.len() && b.starts_with(a)), "{a:?} hides {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user