diff --git a/README.md b/README.md index 67d2c6e..dc5b432 100644 --- a/README.md +++ b/README.md @@ -46,23 +46,30 @@ sauva ``` Sauva - Terminal Unicode Explorer ๐Ÿช„ -Usage: sauva [OPTIONS] [CODE_POINT] +Usage: sauva [OPTIONS] [INPUT] Arguments: - [CODE_POINT] Code point to inspect: character, U+XXXX, 0xXXXX, or hexadecimal + [INPUT] Text or code point to inspect Options: + -t, --text Treat the input as literal text, without code point notation parsing -g, --graphics Control glyph preview graphics [default: auto] [possible values: auto, force, iterm2, off] -h, --help Print help -V, --version Print version ``` -#### Specifying `CODE_POINT` +#### Specifying `INPUT` -The initial code point can be given as one character, `U+` notation, `0x` notation, or two to six hexadecimal digits. +One character opens its code point directly in the Inspector. A sequence of characters opens the Sequence view, where each constituent code point can be selected and inspected. ``` sauva ใ‚ +sauva 'Aฬ๐Ÿ‘ฉโ€๐Ÿ’ป' +``` + +Code points can also be given in `U+` notation, `0x` notation, or as two to six hexadecimal digits. + +``` sauva U+2192 sauva 0x1F600 sauva 1F600 @@ -70,6 +77,15 @@ sauva 1F600 A one-character argument is treated as the character itself. Use a prefix for a one-digit hexadecimal value, such as `U+A` or `0xA`. +An input made entirely of hexadecimal digits keeps the code point interpretation. Use `--text` to force literal text when the input would otherwise be interpreted as notation: + +``` +sauva -t 41 +sauva --text U+2192 +``` + +`--text` still opens the Inspector directly when its value contains only one code point. Empty text is rejected. + #### `-g, --graphics ` - `auto` enables the Kitty graphics protocol for detected kitty and Ghostty terminals. @@ -132,13 +148,13 @@ These are the main built-in controls. The available controls depend on the curre | --- | --- | | F1 | Open or close contextual help | | Ctrl+c | Quit from any view | -| q | Quit from the Inspector or Browse view | -| Esc | Cancel Search or Browse; quit from the Inspector | +| q | Quit from the Inspector, Sequence, or Browse view | +| Esc | Cancel Search or Browse; quit from the Inspector or Sequence view | | h j k l or arrow keys | Move the selection | | / | Search by character, code point, or Unicode name | | p r b c | Browse planes, ranges, blocks, or code points | -| Enter | Open the selected item; confirm a code point from the table | -| Backspace | Return to the previous Browse screen, or cancel from the first screen | +| Enter | Open the selected item; inspect a code point from Sequence or Browse | +| Backspace | Return to the previous Browse screen, or return from Inspector to the input Sequence | | y | Copy the selected Inspector value | Press F1 to view all controls for the current screen. @@ -156,6 +172,7 @@ help = ["f2"] [keybindings.inspector] next_code_point = ["l", "right", "n"] +back = ["backspace"] browse_planes = [] ``` @@ -190,7 +207,25 @@ For each code point, sauva tries configured normal fonts, the system default tex ## Screenshots -Inspector view Plane browser Range browser Code point browser Search results +### Inspector + +Inspector view + +### Browse Plane / Range / Code point + +Plane browser Range browser Code point browser + +### Browse Block + +Block browser Block code point browser + +### Search + +Search results + +### Sequence + +Sequence Sequence inspector view ## License diff --git a/config.schema.json b/config.schema.json index b58a39e..0db337c 100644 --- a/config.schema.json +++ b/config.schema.json @@ -524,6 +524,9 @@ }, "browse_code_points": { "$ref": "#/definitions/keyArray" + }, + "back": { + "$ref": "#/definitions/keyArray" } }, "additionalProperties": false @@ -546,6 +549,30 @@ }, "additionalProperties": false }, + "sequenceKeybindings": { + "type": "object", + "properties": { + "quit": { + "$ref": "#/definitions/keyArray" + }, + "move_up": { + "$ref": "#/definitions/keyArray" + }, + "move_down": { + "$ref": "#/definitions/keyArray" + }, + "first": { + "$ref": "#/definitions/keyArray" + }, + "last": { + "$ref": "#/definitions/keyArray" + }, + "activate": { + "$ref": "#/definitions/keyArray" + } + }, + "additionalProperties": false + }, "browsePlaneKeybindings": { "type": "object", "properties": { @@ -730,6 +757,9 @@ "search": { "$ref": "#/definitions/searchKeybindings" }, + "sequence": { + "$ref": "#/definitions/sequenceKeybindings" + }, "browse_plane": { "$ref": "#/definitions/browsePlaneKeybindings" }, diff --git a/img/demo-sequence.gif b/img/demo-sequence.gif new file mode 100644 index 0000000..5d64c95 Binary files /dev/null and b/img/demo-sequence.gif differ diff --git a/img/demo-sequence.tape b/img/demo-sequence.tape new file mode 100644 index 0000000..ca86da4 --- /dev/null +++ b/img/demo-sequence.tape @@ -0,0 +1,40 @@ +Set Shell zsh + +Output "./img/demo-sequence.gif" + +Set FontSize 11 +Set Width 800 +Set Height 400 +Set Padding 15 +Set WindowBar Colorful +Set Theme "nord" + +Hide + +Env SAUVA_CONFIG_FILE "./img/config.toml" +Type "cargo run --release -- --graphics iterm2 --text 'sauvaโค๏ธโ€๐Ÿ”ฅ'" +Enter +Sleep 2s + +Show + +Sleep 2s + +Screenshot "./img/sequence.png" +Sleep 100ms + +Type@300ms "jjjjjjjj" +Sleep 500ms + +Enter +Sleep 1s + +Screenshot "./img/inspector-sequence.png" +Sleep 100ms + +Backspace +Sleep 500ms + +Type "k" + +Sleep 2s diff --git a/img/inspector-sequence.png b/img/inspector-sequence.png new file mode 100644 index 0000000..0975b9c Binary files /dev/null and b/img/inspector-sequence.png differ diff --git a/img/sequence.png b/img/sequence.png new file mode 100644 index 0000000..3a18caf Binary files /dev/null and b/img/sequence.png differ diff --git a/src/app.rs b/src/app.rs index dccef15..5855a19 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,6 +5,7 @@ use crate::help::{HelpMove, HelpState}; use crate::inspector::{InspectorField, InspectorFieldId, InspectorMove, InspectorState}; use crate::preview::{GlyphPreviewState, GlyphPreviewUpdate}; use crate::search::{SearchMove, SearchState}; +use crate::sequence::{SequenceMove, SequenceState}; use crate::unicode::CodePoint; #[derive(Debug, Clone, PartialEq, Eq)] @@ -37,6 +38,10 @@ pub enum Action { EditSearch(InputRequest), MoveSearch(SearchMove), ResizeSearchViewport(usize), + MoveSequence(SequenceMove), + ResizeSequenceViewport(usize), + InspectSequenceCodePoint, + ReturnToSequence, UpdateGlyphPreview(GlyphPreviewUpdate), ShowFooterStatus(FooterStatus), } @@ -49,6 +54,7 @@ impl Action { | Self::ResizeInspectorViewport { .. } | Self::ResizeBrowserViewport(_) | Self::ResizeSearchViewport(_) + | Self::ResizeSequenceViewport(_) | Self::UpdateGlyphPreview(_) | Self::ShowFooterStatus(_) ) @@ -66,6 +72,7 @@ pub enum View { Inspector, Browser, Search, + Sequence, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -138,6 +145,7 @@ pub struct AppState { inspector: InspectorState, browse: Option, search: Option, + sequence: Option, help: HelpState, glyph_preview: GlyphPreviewState, clipboard_request: Option, @@ -154,6 +162,7 @@ impl AppState { inspector: InspectorState::new(), browse: None, search: None, + sequence: None, help: HelpState::new(), glyph_preview: GlyphPreviewState::new(), clipboard_request: None, @@ -167,6 +176,15 @@ impl AppState { state } + pub fn with_sequence(code_points: Vec) -> Self { + let sequence = SequenceState::new(code_points); + let selected = sequence.selected(); + let mut state = Self::with_selected(selected); + state.view = View::Sequence; + state.sequence = Some(sequence); + state + } + pub fn is_running(&self) -> bool { self.running } @@ -191,6 +209,10 @@ impl AppState { self.search.as_ref() } + pub fn sequence(&self) -> Option<&SequenceState> { + self.sequence.as_ref() + } + pub const fn help(&self) -> HelpState { self.help } @@ -222,6 +244,7 @@ impl AppState { .as_ref() .and_then(SearchState::selected_result) .map(|result| result.code_point()), + View::Sequence => self.sequence.as_ref().map(SequenceState::selected), } } } @@ -379,6 +402,37 @@ pub fn update(state: &mut AppState, action: Action) { search.resize_viewport(height); } } + Action::MoveSequence(movement) if state.view == View::Sequence => { + let previous_preview = state.preview_code_point(); + state + .sequence + .as_mut() + .expect("the sequence view always has sequence state") + .move_selection(movement); + refresh_preview_for_change(state, previous_preview); + } + Action::MoveSequence(_) => {} + Action::ResizeSequenceViewport(height) => { + if let Some(sequence) = state.sequence.as_mut() { + sequence.resize_viewport(height); + } + } + Action::InspectSequenceCodePoint if state.view == View::Sequence => { + let selected = state + .sequence + .as_ref() + .expect("the sequence view always has sequence state") + .selected(); + select_code_point(state, selected); + state.view = View::Inspector; + } + Action::InspectSequenceCodePoint => {} + Action::ReturnToSequence if state.view == View::Inspector && state.sequence.is_some() => { + let previous_preview = state.preview_code_point(); + state.view = View::Sequence; + refresh_preview_for_change(state, previous_preview); + } + Action::ReturnToSequence => {} Action::UpdateGlyphPreview(update) => state.glyph_preview.apply(update), Action::ShowFooterStatus(status) => state.footer_status = Some(status), } @@ -827,6 +881,63 @@ mod tests { assert_eq!(state.search().unwrap().selected_result(), None); } + #[test] + fn sequence_selection_opens_the_inspector_and_returns_to_the_same_position() { + let mut state = + AppState::with_sequence("Aโ†’B".chars().map(CodePoint::from).collect::>()); + update(&mut state, Action::ResizeSequenceViewport(2)); + update(&mut state, Action::MoveSequence(SequenceMove::Next)); + + update(&mut state, Action::InspectSequenceCodePoint); + assert_eq!(state.view(), View::Inspector); + assert_eq!(state.selected().value(), 0x2192); + + update(&mut state, Action::MoveCodePoint(CodePointMove::Next)); + assert_eq!(state.selected().value(), 0x2193); + update(&mut state, Action::ReturnToSequence); + + assert_eq!(state.view(), View::Sequence); + assert_eq!(state.sequence().unwrap().selected_index(), 1); + assert_eq!(state.preview_code_point().unwrap().value(), 0x2192); + } + + #[test] + fn search_from_a_sequence_inspector_does_not_change_the_sequence_position() { + let mut state = + AppState::with_sequence("Aโ†’B".chars().map(CodePoint::from).collect::>()); + update(&mut state, Action::MoveSequence(SequenceMove::Next)); + update(&mut state, Action::InspectSequenceCodePoint); + update(&mut state, Action::OpenSearch); + edit_search_query(&mut state, "ฮฉ"); + update(&mut state, Action::InspectSearchResult); + + assert_eq!(state.view(), View::Inspector); + assert_eq!(state.selected().value(), 0x03a9); + assert_eq!(state.sequence().unwrap().selected_index(), 1); + + update(&mut state, Action::ReturnToSequence); + assert_eq!(state.view(), View::Sequence); + assert_eq!(state.preview_code_point().unwrap().value(), 0x2192); + } + + #[test] + fn browse_from_a_sequence_inspector_returns_through_the_inspector() { + let mut state = + AppState::with_sequence("AB".chars().map(CodePoint::from).collect::>()); + update(&mut state, Action::InspectSequenceCodePoint); + update(&mut state, Action::OpenBrowser(BrowseLevel::CodePointTable)); + update(&mut state, Action::MoveBrowser(BrowseMove::Right)); + update(&mut state, Action::AdvanceBrowser); + + assert_eq!(state.view(), View::Inspector); + assert_eq!(state.selected().value(), 0x0042); + assert_eq!(state.sequence().unwrap().selected_index(), 0); + + update(&mut state, Action::ReturnToSequence); + assert_eq!(state.view(), View::Sequence); + assert_eq!(state.preview_code_point().unwrap().value(), 0x0041); + } + #[test] fn search_cannot_open_from_the_browser() { let mut browser = AppState::new(); diff --git a/src/cli.rs b/src/cli.rs index e544cf4..82007f4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,24 +4,32 @@ use crate::{graphics::GraphicsMode, unicode::CodePoint}; const CODE_POINT_ARGUMENT_HELP: &str = "use one character, U+XXXX, 0xXXXX, or 2-6 hexadecimal digits"; +const INPUT_ARGUMENT_HELP: &str = + "use text, one character, U+XXXX, 0xXXXX, or 2-6 hexadecimal digits"; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LaunchTarget { + CodePoint(CodePoint), + Sequence(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct LaunchOptions { - initial_code_point: Option, + target: Option, demo: Option, graphics: GraphicsMode, } impl LaunchOptions { - pub const fn initial_code_point(self) -> Option { - self.initial_code_point + pub fn target(&self) -> Option<&LaunchTarget> { + self.target.as_ref() } - pub const fn demo(self) -> Option { + pub const fn demo(&self) -> Option { self.demo } - pub const fn graphics(self) -> GraphicsMode { + pub const fn graphics(&self) -> GraphicsMode { if matches!(self.demo, Some(Demo::GlyphDisabled)) { GraphicsMode::Off } else { @@ -34,13 +42,17 @@ impl LaunchOptions { #[derive(Debug, Parser)] #[command(version)] struct Cli { - /// Code point to inspect: character, U+XXXX, 0xXXXX, or hexadecimal + /// Text or code point to inspect #[arg( - value_name = "CODE_POINT", - value_parser = parse_initial_code_point, - conflicts_with = "demo" + value_name = "INPUT", + value_parser = parse_input, + conflicts_with_all = ["text", "demo"] )] - initial_code_point: Option, + input: Option, + + /// Treat the input as literal text, without code point notation parsing + #[arg(short, long, value_name = "TEXT", value_parser = parse_text, conflicts_with = "demo")] + text: Option, /// Control glyph preview graphics #[arg(short, long, value_enum, default_value_t, value_name = "MODE")] @@ -92,25 +104,44 @@ pub fn parse() -> LaunchOptions { impl From for LaunchOptions { fn from(cli: Cli) -> Self { Self { - initial_code_point: cli.initial_code_point, + target: cli.input.or(cli.text), demo: cli.demo, graphics: cli.graphics, } } } -fn parse_initial_code_point(input: &str) -> Result { +fn parse_input(input: &str) -> Result { let mut characters = input.chars(); if let (Some(character), None) = (characters.next(), characters.next()) { - return Ok(CodePoint::from(character)); + return Ok(LaunchTarget::CodePoint(CodePoint::from(character))); } - let (digits, minimum_digits) = input + let prefixed_digits = input .strip_prefix("U+") .or_else(|| input.strip_prefix("u+")) .or_else(|| input.strip_prefix("0x")) - .or_else(|| input.strip_prefix("0X")) - .map_or((input, 2), |digits| (digits, 1)); + .or_else(|| input.strip_prefix("0X")); + if let Some(digits) = prefixed_digits { + return parse_code_point_digits(digits, 1).map(LaunchTarget::CodePoint); + } + if !input.is_empty() && input.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return parse_code_point_digits(input, 2).map(LaunchTarget::CodePoint); + } + + parse_text(input).map_err(|_| INPUT_ARGUMENT_HELP.to_owned()) +} + +fn parse_text(input: &str) -> Result { + let code_points = input.chars().map(CodePoint::from).collect::>(); + match code_points.as_slice() { + [] => Err("text must not be empty".to_owned()), + [code_point] => Ok(LaunchTarget::CodePoint(*code_point)), + _ => Ok(LaunchTarget::Sequence(code_points)), + } +} + +fn parse_code_point_digits(digits: &str, minimum_digits: usize) -> Result { if !(minimum_digits..=6).contains(&digits.len()) || !digits.bytes().all(|byte| byte.is_ascii_hexdigit()) { @@ -142,7 +173,7 @@ mod tests { fn no_arguments_selects_normal_mode() { let options = try_parse(&["sauva"]).unwrap(); - assert_eq!(options.initial_code_point(), None); + assert_eq!(options.target(), None); assert_eq!(options.demo(), None); assert_eq!(options.graphics(), GraphicsMode::Auto); } @@ -161,17 +192,79 @@ mod tests { fn accepts_literal_and_numeric_code_points(#[case] argument: &str, #[case] expected: u32) { let options = try_parse(&["sauva", argument]).unwrap(); - assert_eq!(options.initial_code_point().unwrap().value(), expected); + assert_eq!( + options.target(), + Some(&LaunchTarget::CodePoint(CodePoint::new(expected).unwrap())) + ); + } + + #[rstest] + #[case("hello", &[0x68, 0x65, 0x6c, 0x6c, 0x6f])] + #[case("โค๏ธ", &[0x2764, 0xfe0f])] + #[case("๐Ÿ‘ฉโ€๐Ÿ’ป", &[0x1f469, 0x200d, 0x1f4bb])] + fn accepts_literal_sequences(#[case] argument: &str, #[case] expected: &[u32]) { + let options = try_parse(&["sauva", argument]).unwrap(); + + let Some(LaunchTarget::Sequence(code_points)) = options.target() else { + panic!("expected a sequence"); + }; + assert_eq!( + code_points + .iter() + .map(|code_point| code_point.value()) + .collect::>(), + expected + ); + } + + #[test] + fn text_option_bypasses_code_point_notation_parsing() { + let options = try_parse(&["sauva", "--text", "41"]).unwrap(); + + let Some(LaunchTarget::Sequence(code_points)) = options.target() else { + panic!("expected a sequence"); + }; + assert_eq!( + code_points + .iter() + .map(|code_point| code_point.value()) + .collect::>(), + [0x0034, 0x0031] + ); + } + + #[test] + fn short_text_option_bypasses_code_point_notation_parsing() { + let options = try_parse(&["sauva", "-t", "41"]).unwrap(); + + let Some(LaunchTarget::Sequence(code_points)) = options.target() else { + panic!("expected a sequence"); + }; + assert_eq!( + code_points + .iter() + .map(|code_point| code_point.value()) + .collect::>(), + [0x0034, 0x0031] + ); + } + + #[test] + fn one_character_text_still_selects_a_code_point() { + let options = try_parse(&["sauva", "--text", "A"]).unwrap(); + + assert_eq!( + options.target(), + Some(&LaunchTarget::CodePoint(CodePoint::from('A'))) + ); } #[rstest] - #[case("")] #[case("U+")] #[case("0x")] #[case("0xGG")] #[case("U+00_41")] #[case("0000041")] - #[case("โค๏ธ")] fn rejects_invalid_code_point_arguments(#[case] argument: &str) { let error = try_parse(&["sauva", argument]).unwrap_err(); @@ -179,6 +272,29 @@ mod tests { assert!(error.to_string().contains(CODE_POINT_ARGUMENT_HELP)); } + #[test] + fn rejects_empty_positional_input() { + let error = try_parse(&["sauva", ""]).unwrap_err(); + + assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); + assert!(error.to_string().contains(INPUT_ARGUMENT_HELP)); + } + + #[test] + fn rejects_empty_text() { + let error = try_parse(&["sauva", "--text", ""]).unwrap_err(); + + assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); + assert!(error.to_string().contains("text must not be empty")); + } + + #[test] + fn positional_input_and_text_option_conflict() { + let error = try_parse(&["sauva", "A", "--text", "B"]).unwrap_err(); + + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + } + #[test] fn rejects_code_points_outside_the_unicode_code_space() { let error = try_parse(&["sauva", "U+110000"]).unwrap_err(); @@ -205,8 +321,9 @@ mod tests { fn help_documents_the_graphics_modes() { let help = Cli::command().render_long_help().to_string(); - assert!(help.contains("Usage: sauva [OPTIONS] [CODE_POINT]")); - assert!(help.contains("Code point to inspect")); + assert!(help.contains("Usage: sauva [OPTIONS] [INPUT]")); + assert!(help.contains("Text or code point to inspect")); + assert!(help.contains("-t, --text ")); assert!(help.contains("--graphics ")); assert!(help.contains("possible values: auto, force, iterm2, off")); } diff --git a/src/fixtures.rs b/src/fixtures.rs index 964a2e4..d5077b2 100644 --- a/src/fixtures.rs +++ b/src/fixtures.rs @@ -17,6 +17,26 @@ pub fn startup() -> AppState { selected(0x0041) } +#[cfg(test)] +pub fn sequence() -> AppState { + let mut state = AppState::with_sequence( + "A\u{0301} ๐Ÿ‘ฉโ€๐Ÿ’ป" + .chars() + .map(CodePoint::from) + .collect::>(), + ); + update( + &mut state, + Action::UpdateGlyphPreview(GlyphPreviewUpdate::Configure { + availability: GraphicsAvailability::Unavailable( + GraphicsUnavailableReason::UnsupportedTerminal, + ), + image_id: None, + }), + ); + state +} + pub fn default_ignorable() -> AppState { selected(0x115f) } diff --git a/src/input.rs b/src/input.rs index 2c04fe8..92e469a 100644 --- a/src/input.rs +++ b/src/input.rs @@ -10,6 +10,7 @@ use crate::{ inspector::InspectorMove, keybindings::{Command, Context, KeyChord, ResolvedKeymap}, search::SearchMove, + sequence::SequenceMove, }; pub fn action_for_key(state: &AppState, key: KeyEvent, keymap: &ResolvedKeymap) -> Option { @@ -58,6 +59,7 @@ pub fn context_for_state(state: &AppState) -> Context { match state.view() { View::Inspector => Context::Inspector, View::Search => Context::Search, + View::Sequence => Context::Sequence, View::Browser => match state .browse() .expect("the browser view always has browse state") @@ -100,10 +102,16 @@ fn action_for_command(context: Context, command: Command) -> Option { (X::Inspector, C::BrowseRanges) => Some(Action::OpenBrowser(BrowseLevel::Range)), (X::Inspector, C::BrowseBlocks) => Some(Action::OpenBrowser(BrowseLevel::Block)), (X::Inspector, C::BrowseCodePoints) => Some(Action::OpenBrowser(BrowseLevel::CodePointTable)), + (X::Inspector, C::Back) => Some(Action::ReturnToSequence), (X::Search, C::PreviousResult) => Some(Action::MoveSearch(SearchMove::Previous)), (X::Search, C::NextResult) => Some(Action::MoveSearch(SearchMove::Next)), (X::Search, C::InspectResult) => Some(Action::InspectSearchResult), (X::Search, C::Close) => Some(Action::CloseSearch), + (X::Sequence, C::MoveUp) => Some(Action::MoveSequence(SequenceMove::Previous)), + (X::Sequence, C::MoveDown) => Some(Action::MoveSequence(SequenceMove::Next)), + (X::Sequence, C::First) => Some(Action::MoveSequence(SequenceMove::First)), + (X::Sequence, C::Last) => Some(Action::MoveSequence(SequenceMove::Last)), + (X::Sequence, C::Activate) => Some(Action::InspectSequenceCodePoint), (X::BrowsePlane | X::BrowseRange | X::BrowseBlock | X::BrowseCodePoints, C::MoveUp) => Some(Action::MoveBrowser(BrowseMove::Up)), (X::BrowsePlane | X::BrowseRange | X::BrowseBlock | X::BrowseCodePoints, C::MoveDown) => Some(Action::MoveBrowser(BrowseMove::Down)), (X::BrowsePlane | X::BrowseRange | X::BrowseBlock | X::BrowseCodePoints, C::First) => Some(Action::MoveBrowser(BrowseMove::First)), @@ -125,6 +133,7 @@ mod tests { use tui_input::InputRequest; use super::*; + use crate::unicode::CodePoint; fn action_for_key(state: &AppState, key: KeyEvent) -> Option { super::action_for_key(state, key, &ResolvedKeymap::default()) @@ -647,6 +656,38 @@ mod tests { ); } + #[rstest] + #[case(KeyCode::Up, Action::MoveSequence(SequenceMove::Previous))] + #[case(KeyCode::Char('j'), Action::MoveSequence(SequenceMove::Next))] + #[case(KeyCode::Char('g'), Action::MoveSequence(SequenceMove::First))] + #[case(KeyCode::Char('G'), Action::MoveSequence(SequenceMove::Last))] + #[case(KeyCode::Enter, Action::InspectSequenceCodePoint)] + fn sequence_keys_select_and_inspect_code_points( + #[case] code: KeyCode, + #[case] expected: Action, + ) { + let state = AppState::with_sequence("AB".chars().map(CodePoint::from).collect()); + + assert_eq!( + action_for_key(&state, KeyEvent::new(code, KeyModifiers::NONE)), + Some(expected) + ); + } + + #[test] + fn backspace_returns_from_a_sequence_inspector() { + let mut state = AppState::with_sequence("AB".chars().map(CodePoint::from).collect()); + crate::app::update(&mut state, Action::InspectSequenceCodePoint); + + assert_eq!( + action_for_key( + &state, + KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE) + ), + Some(Action::ReturnToSequence) + ); + } + fn browser_state(level: BrowseLevel) -> AppState { let mut state = AppState::new(); crate::app::update(&mut state, Action::OpenBrowser(level)); diff --git a/src/keybindings.rs b/src/keybindings.rs index ccb731e..21020e7 100644 --- a/src/keybindings.rs +++ b/src/keybindings.rs @@ -38,6 +38,7 @@ pub enum Context { Global, Inspector, Search, + Sequence, BrowsePlane, BrowseRange, BrowseBlock, @@ -51,6 +52,7 @@ impl Context { Self::Global => "global", Self::Inspector => "inspector", Self::Search => "search", + Self::Sequence => "sequence", Self::BrowsePlane => "browse_plane", Self::BrowseRange => "browse_range", Self::BrowseBlock => "browse_block", @@ -281,6 +283,9 @@ pub struct Keybindings { search: SearchKeybindings, #[garde(dive)] #[nested] + sequence: SequenceKeybindings, + #[garde(dive)] + #[nested] browse_plane: BrowsePlaneKeybindings, #[garde(dive)] #[nested] @@ -302,6 +307,7 @@ impl Keybindings { self.global.append_bindings(&mut bindings); self.inspector.append_bindings(&mut bindings); self.search.append_bindings(&mut bindings); + self.sequence.append_bindings(&mut bindings); self.browse_plane.append_bindings(&mut bindings); self.browse_range.append_bindings(&mut bindings); self.browse_block.append_bindings(&mut bindings); @@ -368,6 +374,7 @@ keybinding_context!(InspectorKeybindings, Context::Inspector, { browse_ranges => (Command::BrowseRanges, ["r"]), browse_blocks => (Command::BrowseBlocks, ["b"]), browse_code_points => (Command::BrowseCodePoints, ["c"]), + back => (Command::Back, ["backspace"]), }); keybinding_context!(SearchKeybindings, Context::Search, { @@ -377,6 +384,15 @@ keybinding_context!(SearchKeybindings, Context::Search, { close => (Command::Close, ["esc"]), }); +keybinding_context!(SequenceKeybindings, Context::Sequence, { + quit => (Command::Quit, ["q", "esc"]), + move_up => (Command::MoveUp, ["k", "up"]), + move_down => (Command::MoveDown, ["j", "down"]), + first => (Command::First, ["g"]), + last => (Command::Last, ["G"]), + activate => (Command::Activate, ["enter"]), +}); + keybinding_context!(BrowsePlaneKeybindings, Context::BrowsePlane, { quit => (Command::Quit, ["q"]), close => (Command::Close, ["esc"]), @@ -715,6 +731,7 @@ impl ResolvedKeymap { for context in [ Context::Inspector, Context::Search, + Context::Sequence, Context::BrowsePlane, Context::BrowseRange, Context::BrowseBlock, @@ -964,6 +981,26 @@ mod tests { assert_eq!(keymap.resolve(Context::Inspector, plain('l')), None); } + #[test] + fn inspector_back_binding_can_be_configured() { + let keymap = configured( + r#" + [inspector] + back = ["x"] + "#, + ) + .unwrap(); + + assert_eq!( + keymap.resolve(Context::Inspector, plain('x')), + Some(Command::Back) + ); + assert_eq!( + keymap.resolve(Context::Inspector, named(KeyCode::Backspace)), + None + ); + } + #[test] fn an_empty_array_disables_only_the_requested_context_binding() { let keymap = configured( diff --git a/src/main.rs b/src/main.rs index 4fac8c6..a765029 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod inspector; mod keybindings; mod preview; mod search; +mod sequence; mod terminal; mod ui; mod unicode; @@ -33,7 +34,7 @@ fn main() -> ExitCode { fn run() -> Result<(), Box> { let options = cli::parse(); let config = config::load()?; - let mut state = initial_state(options.demo(), options.initial_code_point()); + let mut state = initial_state(options.demo(), options.target()); terminal::run( &mut state, @@ -46,13 +47,15 @@ fn run() -> Result<(), Box> { Ok(()) } -fn initial_state( - demo: Option, - initial_code_point: Option, -) -> app::AppState { - debug_assert!(demo.is_none() || initial_code_point.is_none()); - if let Some(code_point) = initial_code_point { - return app::AppState::with_selected(code_point); +fn initial_state(demo: Option, target: Option<&cli::LaunchTarget>) -> app::AppState { + debug_assert!(demo.is_none() || target.is_none()); + if let Some(target) = target { + return match target { + cli::LaunchTarget::CodePoint(code_point) => app::AppState::with_selected(*code_point), + cli::LaunchTarget::Sequence(code_points) => { + app::AppState::with_sequence(code_points.clone()) + } + }; } match demo { @@ -93,14 +96,24 @@ fn initial_state( #[cfg(test)] mod tests { - use crate::{initial_state, unicode::CodePoint}; + use crate::{cli::LaunchTarget, initial_state, unicode::CodePoint}; #[test] fn initial_code_point_selects_the_inspected_value() { let code_point = CodePoint::new(0x2192).unwrap(); - let state = initial_state(None, Some(code_point)); + let state = initial_state(None, Some(&LaunchTarget::CodePoint(code_point))); assert_eq!(state.selected(), code_point); } + + #[test] + fn initial_sequence_opens_the_sequence_view() { + let code_points = "Aโ†’B".chars().map(CodePoint::from).collect::>(); + + let state = initial_state(None, Some(&LaunchTarget::Sequence(code_points))); + + assert_eq!(state.view(), crate::app::View::Sequence); + assert_eq!(state.sequence().unwrap().selected().value(), 0x0041); + } } diff --git a/src/sequence.rs b/src/sequence.rs new file mode 100644 index 0000000..e51baf6 --- /dev/null +++ b/src/sequence.rs @@ -0,0 +1,121 @@ +use std::ops::Range; + +use crate::{unicode::CodePoint, viewport::ListViewport}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SequenceMove { + Previous, + Next, + First, + Last, +} + +#[derive(Debug, Clone)] +pub struct SequenceState { + code_points: Vec, + selected_index: usize, + viewport: ListViewport, +} + +impl SequenceState { + pub fn new(code_points: Vec) -> Self { + assert!( + code_points.len() > 1, + "a sequence contains at least two code points" + ); + Self { + code_points, + selected_index: 0, + viewport: ListViewport::new(), + } + } + + pub fn code_points(&self) -> &[CodePoint] { + &self.code_points + } + + pub const fn selected_index(&self) -> usize { + self.selected_index + } + + pub fn selected(&self) -> CodePoint { + self.code_points[self.selected_index] + } + + pub fn visible_range(&self) -> Range { + self.viewport.visible_range(self.code_points.len()) + } + + pub fn move_selection(&mut self, movement: SequenceMove) -> bool { + let last = self.code_points.len() - 1; + let next = match movement { + SequenceMove::Previous => self.selected_index.saturating_sub(1), + SequenceMove::Next => (self.selected_index + 1).min(last), + SequenceMove::First => 0, + SequenceMove::Last => last, + }; + if next == self.selected_index { + return false; + } + + self.selected_index = next; + self.viewport.ensure_visible(next, self.code_points.len()); + true + } + + pub fn resize_viewport(&mut self, height: usize) { + self.viewport + .resize(height, self.selected_index, self.code_points.len()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state() -> SequenceState { + SequenceState::new("Aโ†’A".chars().map(CodePoint::from).collect()) + } + + #[test] + fn preserves_input_order_and_duplicates() { + let state = state(); + + assert_eq!( + state + .code_points() + .iter() + .map(|code_point| code_point.value()) + .collect::>(), + [0x0041, 0x2192, 0x0041] + ); + assert_eq!(state.selected_index(), 0); + assert_eq!(state.selected().value(), 0x0041); + } + + #[test] + fn moves_within_the_sequence_and_keeps_the_selection_visible() { + let mut state = state(); + state.resize_viewport(2); + + assert!(state.move_selection(SequenceMove::Last)); + assert_eq!(state.selected_index(), 2); + assert_eq!(state.visible_range(), 1..3); + assert!(state.move_selection(SequenceMove::Previous)); + assert_eq!(state.selected_index(), 1); + assert_eq!(state.visible_range(), 1..3); + assert!(state.move_selection(SequenceMove::First)); + assert_eq!(state.selected_index(), 0); + assert_eq!(state.visible_range(), 0..2); + } + + #[test] + fn stops_at_the_sequence_boundaries() { + let mut state = state(); + + assert!(!state.move_selection(SequenceMove::Previous)); + assert!(state.move_selection(SequenceMove::Next)); + assert!(state.move_selection(SequenceMove::Next)); + assert!(!state.move_selection(SequenceMove::Next)); + } +} diff --git a/src/terminal.rs b/src/terminal.rs index 847beeb..30a4488 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -22,7 +22,7 @@ use crate::{ keybindings::ResolvedKeymap, ui::{ self, inspector, - layout::{browser_list_height, search_result_height}, + layout::{browser_list_height, search_result_height, sequence_list_height}, render, settings::UiSettings, theme::ColorTheme, @@ -158,6 +158,7 @@ fn resize_active_view(state: &mut AppState, area: Rect, keymap: &ResolvedKeymap) } View::Browser => Action::ResizeBrowserViewport(browser_list_height(area)), View::Search => Action::ResizeSearchViewport(search_result_height(area)), + View::Sequence => Action::ResizeSequenceViewport(sequence_list_height(area)), }; update(state, action); } diff --git a/src/ui.rs b/src/ui.rs index 9375d31..c4dabde 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -8,6 +8,7 @@ pub mod render; mod scrollbar; mod search; mod selection_preview; +mod sequence; pub mod settings; pub mod theme; @@ -39,6 +40,9 @@ pub fn glyph_preview_request(area: Rect, state: &AppState) -> Option layout::search(shell.main) .preview .and_then(|preview| selection_preview::glyph_area(preview, code_point)), + View::Sequence => layout::sequence(shell.main) + .context + .and_then(|context| selection_preview::glyph_area(context, code_point)), }?; Some(GlyphPreviewRequest { code_point, diff --git a/src/ui/help.rs b/src/ui/help.rs index 2640de3..309e52f 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -90,6 +90,7 @@ pub fn viewport_metrics(area: Rect, state: &AppState, keymap: &ResolvedKeymap) - let context = context_for_state(state); let document = document( context, + state.sequence().is_some(), keymap, Style::new(), Style::new(), @@ -120,6 +121,7 @@ pub fn render( let help_content = help_block.inner(sections.help); let document = document( context, + state.sequence().is_some(), keymap, Style::new().fg(color_theme.fg), Style::new().fg(color_theme.help.key), @@ -178,8 +180,8 @@ fn about_document( ]) } -pub fn footer(context: Context, width: u16, keymap: &ResolvedKeymap) -> String { - let mut items = short_help_items(context) +pub fn footer(context: Context, has_sequence: bool, width: u16, keymap: &ResolvedKeymap) -> String { + let mut items = short_help_items(context, has_sequence) .into_iter() .filter_map(|item| render_short_help_item(context, item, keymap)) .collect::>(); @@ -212,12 +214,13 @@ pub fn footer(context: Context, width: u16, keymap: &ResolvedKeymap) -> String { fn document( context: Context, + has_sequence: bool, keymap: &ResolvedKeymap, normal_style: Style, key_style: Style, width: usize, ) -> Text<'static> { - let mut lines = help_items(context) + let mut lines = help_items(context, has_sequence) .into_iter() .filter_map(|item| help_lines(context, item, keymap, normal_style, key_style, width)) .flatten() @@ -323,7 +326,7 @@ fn wrap_words(text: &str, width: usize) -> Vec { lines } -fn help_items(context: Context) -> Vec { +fn help_items(context: Context, has_sequence: bool) -> Vec { use Command as C; let mut items = match context { @@ -342,6 +345,7 @@ fn help_items(context: Context) -> Vec { item(C::BrowseRanges, "Browse ranges"), item(C::BrowseBlocks, "Browse blocks"), item(C::BrowseCodePoints, "Browse code points"), + item(C::Back, "Return to the input sequence"), item(C::Quit, "Quit"), ], Context::Search => vec![ @@ -351,6 +355,14 @@ fn help_items(context: Context) -> Vec { item(C::Close, "Close search"), item(C::Quit, "Quit"), ], + Context::Sequence => vec![ + item(C::MoveUp, "Select the previous code point"), + item(C::MoveDown, "Select the next code point"), + item(C::First, "Select the first code point"), + item(C::Last, "Select the last code point"), + item(C::Activate, "Inspect the selected code point"), + item(C::Quit, "Quit"), + ], Context::BrowsePlane => vec![ item(C::MoveUp, "Select the previous plane"), item(C::MoveDown, "Select the next plane"), @@ -414,6 +426,9 @@ fn help_items(context: Context) -> Vec { ], Context::Global => vec![item(C::Quit, "Quit")], }; + if !has_sequence && context == Context::Inspector { + items.retain(|item| item.command != C::Back); + } items.push(item(C::Help, "Open or close help")); items } @@ -426,10 +441,10 @@ fn item(command: Command, description: &'static str) -> HelpItem { } #[rustfmt::skip] -fn short_help_items(context: Context) -> Vec { +fn short_help_items(context: Context, has_sequence: bool) -> Vec { use Command as C; - match context { + let mut items = match context { Context::Inspector => vec![ short(&[C::PreviousCodePoint, C::NextCodePoint], "Point", 1), short(&[C::MoveUp, C::MoveDown], "Field", 1), @@ -438,6 +453,7 @@ fn short_help_items(context: Context) -> Vec { short(&[C::CopyValue], "Copy", 1), short(&[C::OpenSearch], "Search", 2), short(&[C::BrowsePlanes, C::BrowseRanges, C::BrowseBlocks, C::BrowseCodePoints], "Browse", 2), + short(&[C::Back], "Sequence", 1), short(&[C::Quit], "Quit", 0), short(&[C::Help], "Help", 0), ], @@ -448,6 +464,13 @@ fn short_help_items(context: Context) -> Vec { short(&[C::Quit], "Quit", 2), short(&[C::Help], "Help", 0), ], + Context::Sequence => vec![ + short(&[C::MoveUp, C::MoveDown], "Move", 1), + short(&[C::First, C::Last], "Ends", 3), + short(&[C::Activate], "Inspect", 1), + short(&[C::Quit], "Quit", 0), + short(&[C::Help], "Help", 0), + ], Context::BrowsePlane => vec![ short(&[C::MoveUp, C::MoveDown], "Move", 1), short(&[C::First, C::Last], "Ends", 3), @@ -497,7 +520,11 @@ fn short_help_items(context: Context) -> Vec { Context::Global => vec![ short(&[C::Quit], "Quit", 0), short(&[C::Help], "Help", 0), ], + }; + if !has_sequence && context == Context::Inspector { + items.retain(|item| !item.commands.contains(&C::Back)); } + items } fn short(commands: &[Command], description: &'static str, priority: u8) -> ShortHelpItem { @@ -546,6 +573,7 @@ fn context_label(context: Context) -> &'static str { Context::Global => "Global", Context::Inspector => "Inspector", Context::Search => "Search", + Context::Sequence => "Sequence", Context::BrowsePlane => "Browse Planes", Context::BrowseRange => "Browse Ranges", Context::BrowseBlock => "Browse Blocks", @@ -649,8 +677,8 @@ mod tests { #[test] fn footer_prunes_lower_priority_items_to_fit() { let keymap = ResolvedKeymap::default(); - let wide = footer(Context::Inspector, 140, &keymap); - let narrow = footer(Context::Inspector, 60, &keymap); + let wide = footer(Context::Inspector, false, 140, &keymap); + let narrow = footer(Context::Inspector, false, 60, &keymap); assert!(Line::from(wide.as_str()).width() <= 140); assert!(Line::from(narrow.as_str()).width() <= 60); @@ -664,6 +692,7 @@ mod tests { fn full_help_resolves_context_and_global_keys() { let text = document( Context::Inspector, + false, &ResolvedKeymap::default(), Style::new(), Style::new(), @@ -686,6 +715,7 @@ mod tests { fn search_help_is_context_specific_and_explains_raw_input() { let text = document( Context::Search, + false, &ResolvedKeymap::default(), Style::new(), Style::new(), @@ -703,6 +733,33 @@ mod tests { assert!(!rendered.contains("Browse code points")); } + #[test] + fn inspector_help_only_offers_a_sequence_return_when_one_exists() { + let keymap = ResolvedKeymap::default(); + let without_sequence = document( + Context::Inspector, + false, + &keymap, + Style::new(), + Style::new(), + 80, + ) + .to_string(); + let with_sequence = document( + Context::Inspector, + true, + &keymap, + Style::new(), + Style::new(), + 80, + ) + .to_string(); + + assert!(!without_sequence.contains("Return to the input sequence")); + assert!(with_sequence.contains("Return to the input sequence")); + assert!(footer(Context::Inspector, true, 140, &keymap).contains("BS: Sequence")); + } + #[test] fn help_uses_configured_keys_and_omits_disabled_commands() { let config: OptionalKeybindings = toml::from_str( @@ -714,13 +771,20 @@ mod tests { ) .unwrap(); let keymap = ResolvedKeymap::with_config(config.into()).unwrap(); - let footer = footer(Context::Inspector, 140, &keymap); - let help = document(Context::Inspector, &keymap, Style::new(), Style::new(), 80) - .lines - .iter() - .map(|line| line.to_string()) - .collect::>() - .join("\n"); + let footer = footer(Context::Inspector, false, 140, &keymap); + let help = document( + Context::Inspector, + false, + &keymap, + Style::new(), + Style::new(), + 80, + ) + .lines + .iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); assert!(footer.contains("h/n: Point")); assert!(!footer.contains("/: Search")); diff --git a/src/ui/inspector.rs b/src/ui/inspector.rs index 600dec5..7bcfd8b 100644 --- a/src/ui/inspector.rs +++ b/src/ui/inspector.rs @@ -62,7 +62,7 @@ pub fn render( frame.render_widget( Paragraph::new(lines).block( Block::bordered() - .title(" Inspector ") + .title(inspector_title(state)) .padding(Padding::horizontal(1)), ), area, @@ -73,6 +73,19 @@ pub fn render( ); } +fn inspector_title(state: &AppState) -> String { + state.sequence().map_or_else( + || " Inspector ".to_owned(), + |sequence| { + format!( + " Inspector ยท from Sequence {}/{} ", + sequence.selected_index() + 1, + sequence.code_points().len() + ) + }, + ) +} + fn content_area(area: Rect) -> Rect { Block::bordered() .padding(Padding::horizontal(1)) diff --git a/src/ui/layout.rs b/src/ui/layout.rs index b98d977..e6ad539 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -82,6 +82,10 @@ pub fn search(area: Rect) -> SearchLayout { } } +pub fn sequence(area: Rect) -> BrowserLayout { + navigator_and_context(area) +} + fn navigator_and_context(area: Rect) -> BrowserLayout { if area.width < NAVIGATOR_WIDTH + CONTEXT_MINIMUM_WIDTH { return BrowserLayout { @@ -113,6 +117,12 @@ pub fn search_result_height(area: Rect) -> usize { }) } +pub fn sequence_list_height(area: Rect) -> usize { + calculate(area).map_or(0, |layout| { + usize::from(sequence(layout.main).navigator.height.saturating_sub(2)) + }) +} + #[cfg(test)] mod tests { use rstest::rstest; diff --git a/src/ui/render.rs b/src/ui/render.rs index 2fdd3c6..c34cd57 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -12,7 +12,7 @@ use crate::{ ui::{ browser, glyph_preview, help, inspector, layout::{MINIMUM_SIZE, calculate}, - search, + search, sequence, settings::UiSettings, theme::ColorTheme, }, @@ -45,6 +45,7 @@ pub fn render( } View::Browser => browser::render(frame, layout.main, state, color_theme, ui), View::Search => search::render(frame, layout.main, state, color_theme, ui), + View::Sequence => sequence::render(frame, layout.main, state, color_theme, ui), } } render_footer(frame, layout.footer, state, keymap, color_theme); @@ -89,7 +90,7 @@ fn render_footer( } else { context_for_state(state) }; - let text = help::footer(context, area.width, keymap); + let text = help::footer(context, state.sequence().is_some(), area.width, keymap); frame.render_widget( Paragraph::new(text).style(Style::new().fg(color_theme.footer.short_help)), area, @@ -114,6 +115,7 @@ mod tests { ui::inspector, ui::layout::{ MINIMUM_SIZE, STANDARD_SIZE, WIDE_SIZE, browser_list_height, search_result_height, + sequence_list_height, }, ui::theme::{ CodePointTableColors, FooterColors, GlyphPreviewColors, HelpColors, InspectorColors, @@ -155,6 +157,10 @@ mod tests { &mut state, Action::ResizeSearchViewport(search_result_height(Rect::new(0, 0, width, height))), ); + update( + &mut state, + Action::ResizeSequenceViewport(sequence_list_height(Rect::new(0, 0, width, height))), + ); let backend = TestBackend::new(width, height); let mut terminal = Terminal::new(backend).unwrap(); terminal @@ -614,6 +620,22 @@ mod tests { insta::assert_snapshot!(render_to_text(&state, width, height)); } + #[test] + fn sequence_standard() { + let state = fixtures::sequence(); + let (width, height) = STANDARD_SIZE; + + insta::assert_snapshot!(render_to_text(&state, width, height)); + } + + #[test] + fn sequence_minimum() { + let state = fixtures::sequence(); + let (width, height) = MINIMUM_SIZE; + + insta::assert_snapshot!(render_to_text(&state, width, height)); + } + #[test] fn glyph_wide() { let state = fixtures::glyph_wide(); diff --git a/src/ui/sequence.rs b/src/ui/sequence.rs new file mode 100644 index 0000000..7b15802 --- /dev/null +++ b/src/ui/sequence.rs @@ -0,0 +1,120 @@ +use ratatui::{ + Frame, + layout::Rect, + text::Line, + widgets::{Block, Padding, Paragraph}, +}; + +use crate::{ + app::AppState, + ui::{ + layout, + scrollbar::{self, ViewportScrollbar}, + selectable_list_line, selection_preview, + settings::UiSettings, + theme::ColorTheme, + }, + unicode::UnicodeDatabase, +}; + +pub fn render( + frame: &mut Frame, + area: Rect, + state: &AppState, + color_theme: &ColorTheme, + ui: &UiSettings, +) { + let layout = layout::sequence(area); + let sequence = state + .sequence() + .expect("the sequence view always has sequence state"); + let count = sequence.code_points().len(); + let block = Block::bordered() + .title(format!(" Sequence ยท {count} code points ")) + .padding(Padding::horizontal(1)); + let content = block.inner(layout.navigator); + let position_width = count.to_string().len(); + let visible = sequence.visible_range(); + let rows = visible + .clone() + .map(|index| { + let code_point = sequence.code_points()[index]; + let selected = sequence.selected_index() == index; + let marker = ui.selection_marker(selected); + let representation = UnicodeDatabase::display_representation(code_point); + let name = UnicodeDatabase::primary_name_or_fallback(code_point); + let code_point = format!("{code_point:<8}", code_point = code_point.to_string()); + selectable_list_line( + Line::from(format!( + "{marker} {:>position_width$} {code_point} {representation} โ€” {name}", + index + 1 + )), + selected, + content.width, + color_theme.list.selection, + ) + }) + .collect::>(); + + frame.render_widget(Paragraph::new(rows).block(block), layout.navigator); + frame.render_widget( + ViewportScrollbar::new(count, visible).style(color_theme.base_style()), + scrollbar::area_after(content), + ); + + if let Some(context) = layout.context { + selection_preview::render( + frame, + context, + sequence.selected(), + state.glyph_preview(), + color_theme, + ); + } +} + +#[cfg(test)] +mod tests { + use ratatui::{Terminal, backend::TestBackend}; + + use super::*; + use crate::{ + app::{Action, AppState, update}, + unicode::CodePoint, + }; + + #[test] + fn renders_positions_code_points_and_names_in_input_order() { + let mut state = AppState::with_sequence("Aโ†’A".chars().map(CodePoint::from).collect()); + update(&mut state, Action::ResizeSequenceViewport(27)); + let backend = TestBackend::new(100, 29); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|frame| { + render( + frame, + frame.area(), + &state, + &ColorTheme::default(), + &UiSettings::default(), + ); + }) + .unwrap(); + let text = + terminal + .backend() + .buffer() + .content() + .iter() + .fold(String::new(), |mut text, cell| { + text.push_str(cell.symbol()); + text + }); + + assert!(text.contains("Sequence ยท 3 code points")); + assert!(text.contains("1 U+0041")); + assert!(text.contains("2 U+2192")); + assert_eq!(text.matches("LATIN CAPITAL LETTER A").count(), 2); + } +} diff --git a/src/ui/snapshots/sauva__ui__render__tests__sequence_minimum.snap b/src/ui/snapshots/sauva__ui__render__tests__sequence_minimum.snap new file mode 100644 index 0000000..0d0b52b --- /dev/null +++ b/src/ui/snapshots/sauva__ui__render__tests__sequence_minimum.snap @@ -0,0 +1,20 @@ +--- +source: src/ui/render.rs +expression: "render_to_text(&state, width, height)" +--- +โ”Œ Sequence ยท 6 code points โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ 1 U+0041 A โ€” LATIN CAPITAL LETTER A โ”‚ +โ”‚ 2 U+0301 โ—Œฬ โ€” COMBINING ACUTE ACCENT โ”‚ +โ”‚ 3 U+0020 โ€” SPACE โ”‚ +โ”‚ 4 U+1F469 ๐Ÿ‘ฉ โ€” WOMAN โ”‚ +โ”‚ 5 U+200D โ€” ZERO WIDTH JOINER โ”‚ +โ”‚ 6 U+1F4BB ๐Ÿ’ป โ€” PERSONAL COMPUTER โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + k/j: Move g/G: Ends Enter: Inspect q: Quit F1: Help diff --git a/src/ui/snapshots/sauva__ui__render__tests__sequence_standard.snap b/src/ui/snapshots/sauva__ui__render__tests__sequence_standard.snap new file mode 100644 index 0000000..9e01192 --- /dev/null +++ b/src/ui/snapshots/sauva__ui__render__tests__sequence_standard.snap @@ -0,0 +1,34 @@ +--- +source: src/ui/render.rs +expression: "render_to_text(&state, width, height)" +--- +โ”Œ Sequence ยท 6 code points โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”Œ Selection Preview โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ 1 U+0041 A โ€” LATIN CAPITAL LETTER A โ”‚โ”‚ Character A โ”‚ +โ”‚ 2 U+0301 โ—Œฬ โ€” COMBINING ACUTE ACCENT โ”‚โ”‚ Code Point U+0041 โ”‚ +โ”‚ 3 U+0020 โ€” SPACE โ”‚โ”‚ Primary Name LATIN CAPITAL โ”‚ +โ”‚ 4 U+1F469 ๐Ÿ‘ฉ โ€” WOMAN โ”‚โ”‚ LETTER A โ”‚ +โ”‚ 5 U+200D โ€” ZERO WIDTH JOINER โ”‚โ”‚ Block Basic Latin โ”‚ +โ”‚ 6 U+1F4BB ๐Ÿ’ป โ€” PERSONAL COMPUTER โ”‚โ”‚ General Category Lu โ€” Uppercase โ”‚ +โ”‚ โ”‚โ”‚ Letter โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ”‚ โ”‚โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + k/j: Move g/G: Ends Enter: Inspect q: Quit F1: Help diff --git a/tests/terminal_e2e.rs b/tests/terminal_e2e.rs index 51b14d8..d085330 100644 --- a/tests/terminal_e2e.rs +++ b/tests/terminal_e2e.rs @@ -92,6 +92,52 @@ fn starts_at_the_code_point_from_the_command_line() -> termlens::Result<()> { Ok(()) } +#[test] +fn inspects_a_text_sequence_and_preserves_its_position_across_search() -> termlens::Result<()> { + let mut terminal = termlens::bin!("sauva", size(100, 30), args(["Aโ†’B", "--graphics", "off"]))?; + terminal.snapshot_after(|screen| { + screen.contains("Sequence ยท 3 code points") + && screen.contains("U+0041") + && screen.contains("U+2192") + && screen.contains("U+0042") + })?; + + terminal.send(Key::Down)?; + terminal.send(Key::Enter)?; + terminal.wait_until(|screen| { + screen.contains("Inspector ยท from Sequence 2/3") + && screen.contains("U+2192") + && screen.contains("RIGHTWARDS ARROW") + })?; + + terminal.send(Key::Char('/'))?; + terminal.wait_until(|screen| screen.contains("Search by Unicode name"))?; + terminal.send_str("ฮฉ")?; + terminal.wait_until(|screen| { + screen.contains("U+03A9") && screen.contains("GREEK CAPITAL LETTER OMEGA") + })?; + terminal.send(Key::Enter)?; + terminal.wait_until(|screen| { + screen.contains("Inspector ยท from Sequence 2/3") + && screen.contains("U+03A9") + && screen.contains("GREEK CAPITAL LETTER OMEGA") + })?; + + terminal.send(Key::Backspace)?; + terminal.wait_until(|screen| screen.contains("Sequence ยท 3 code points"))?; + terminal.send(Key::Enter)?; + terminal.wait_until(|screen| { + screen.contains("Inspector ยท from Sequence 2/3") + && screen.contains("U+2192") + && screen.contains("RIGHTWARDS ARROW") + })?; + + terminal.send(Key::Char('q'))?; + let status = terminal.wait_exit()?; + assert!(status.success(), "exit status: {status}"); + Ok(()) +} + #[test] fn searches_for_a_name_and_opens_the_result_in_the_inspector() -> termlens::Result<()> { let mut terminal = spawn("off")?;