From 08dd5c16f044225d9de08c5f3f0e276400dc8e3d Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sat, 19 Sep 2026 18:11:46 +0900 Subject: [PATCH 1/2] Add configurable search input cursor modes --- src/config.rs | 62 ++++++++++++++++++++++++++++ src/ui/browser.rs | 1 + src/ui/render.rs | 40 ++++++++++++++++++ src/ui/search.rs | 100 +++++++++++++++++++++++++++++++++++++++------ src/ui/settings.rs | 44 ++++++++++++++++++++ 5 files changed, 234 insertions(+), 13 deletions(-) diff --git a/src/config.rs b/src/config.rs index de82edf..f4e144f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -458,6 +458,68 @@ mod tests { assert_eq!(configured.ui().selection_cursor, ">"); assert_eq!(defaults.ui().selection_cursor, ""); + assert_eq!( + configured.ui().input_cursor, + crate::ui::settings::InputCursor::Native + ); + assert_eq!( + defaults.ui().input_cursor, + crate::ui::settings::InputCursor::Native + ); + } + + #[test] + fn text_input_cursor_is_loaded_from_config() { + let directory = tempdir().unwrap(); + let path = directory.path().join("config.toml"); + fs::write( + &path, + r#" + [ui] + input_cursor = { text = "|" } + "#, + ) + .unwrap(); + + let configured = load_config(Some(&ConfigFile::Explicit(path.clone()))).unwrap(); + assert_eq!( + configured.ui().input_cursor, + crate::ui::settings::InputCursor::Text("|".to_owned()) + ); + + fs::write( + &path, + r#" + [ui] + input_cursor = "native" + "#, + ) + .unwrap(); + let configured = load_config(Some(&ConfigFile::Explicit(path))).unwrap(); + assert_eq!( + configured.ui().input_cursor, + crate::ui::settings::InputCursor::Native + ); + } + + #[test] + fn invalid_text_input_cursor_reports_its_setting() { + let directory = tempdir().unwrap(); + let path = directory.path().join("config.toml"); + fs::write( + &path, + r#" + [ui] + input_cursor = { text = "界" } + "#, + ) + .unwrap(); + + let error = load_config(Some(&ConfigFile::Explicit(path.clone()))).unwrap_err(); + let message = error.to_string(); + assert!(matches!(error, ConfigError::Validate { .. })); + assert!(message.contains(&path.display().to_string())); + assert!(message.contains("ui.input_cursor")); } #[test] diff --git a/src/ui/browser.rs b/src/ui/browser.rs index 7801d86..d891ddd 100644 --- a/src/ui/browser.rs +++ b/src/ui/browser.rs @@ -299,6 +299,7 @@ mod tests { let code_point = CodePoint::new(value).unwrap(); let ui = UiSettings { selection_cursor: ">".to_owned(), + ..Default::default() }; let cell = table_cell(code_point, selected, &ui); diff --git a/src/ui/render.rs b/src/ui/render.rs index 85c30e6..26abbc0 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -542,6 +542,7 @@ mod tests { let color_theme = test_color_theme(); let ui = UiSettings { selection_cursor: ">".to_owned(), + ..Default::default() }; let (width, height) = STANDARD_SIZE; @@ -898,4 +899,43 @@ mod tests { terminal.backend_mut().assert_cursor_position((18, 1)); } + + #[test] + fn text_input_cursor_hides_the_terminal_cursor_in_search_and_help() { + let mut state = fixtures::search_name_results(); + let (width, height) = STANDARD_SIZE; + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + let ui = UiSettings { + input_cursor: crate::ui::settings::InputCursor::Text("|".to_owned()), + ..Default::default() + }; + + terminal + .draw(|frame| { + render( + frame, + &state, + &ColorTheme::default(), + &ui, + &ResolvedKeymap::default(), + ) + }) + .unwrap(); + assert!(!terminal.backend().cursor_visible()); + + update(&mut state, Action::ToggleHelp); + terminal + .draw(|frame| { + render( + frame, + &state, + &ColorTheme::default(), + &ui, + &ResolvedKeymap::default(), + ) + }) + .unwrap(); + assert!(!terminal.backend().cursor_visible()); + } } diff --git a/src/ui/search.rs b/src/ui/search.rs index d8efdf6..c04c7ad 100644 --- a/src/ui/search.rs +++ b/src/ui/search.rs @@ -13,7 +13,7 @@ use crate::{ layout, scrollbar::{self, ViewportScrollbar}, selectable_list_line, selection_preview, - settings::UiSettings, + settings::{InputCursor, UiSettings}, theme::ColorTheme, }, unicode::UnicodeDatabase, @@ -34,7 +34,7 @@ pub fn render( .search() .expect("the search view always has search state"); - render_input(frame, layout.input, search.input()); + render_input(frame, layout.input, search.input(), &ui.input_cursor); render_results( frame, layout.results, @@ -60,22 +60,37 @@ pub fn render( } } -fn render_input(frame: &mut Frame, area: Rect, input: &tui_input::Input) { +fn render_input(frame: &mut Frame, area: Rect, input: &tui_input::Input, cursor: &InputCursor) { let width = area.width.saturating_sub(4).max(1); - let scroll = input.visual_scroll(usize::from(width)); + let scroll = match cursor { + InputCursor::Native => input.visual_scroll(usize::from(width)), + InputCursor::Text(_) => input.visual_scroll(usize::from(width.saturating_sub(1).max(1))), + }; + let content = match cursor { + InputCursor::Native => Line::raw(input.value()), + InputCursor::Text(text) => { + let byte_index = input + .value() + .char_indices() + .nth(input.cursor()) + .map_or(input.value().len(), |(index, _)| index); + let (before, after) = input.value().split_at(byte_index); + Line::from(vec![Span::raw(before), Span::raw(text), Span::raw(after)]) + } + }; frame.render_widget( - Paragraph::new(input.value()) - .scroll((0, scroll as u16)) - .block( - Block::bordered() - .title(" Search ") - .padding(Padding::horizontal(1)), - ), + Paragraph::new(content).scroll((0, scroll as u16)).block( + Block::bordered() + .title(" Search ") + .padding(Padding::horizontal(1)), + ), area, ); - let cursor = input.visual_cursor().max(scroll) - scroll; - frame.set_cursor_position((area.x + cursor as u16 + 2, area.y + 1)); + if matches!(cursor, InputCursor::Native) { + let cursor = input.visual_cursor().max(scroll) - scroll; + frame.set_cursor_position((area.x + cursor as u16 + 2, area.y + 1)); + } } fn render_results( @@ -260,6 +275,7 @@ fn render_empty_preview(frame: &mut Frame, area: Rect, color_theme: &ColorTheme) #[cfg(test)] mod tests { + use ratatui::{Terminal, backend::TestBackend}; use rstest::rstest; use super::*; @@ -268,6 +284,63 @@ mod tests { unicode::CodePoint, }; + fn rendered_input(input: &tui_input::Input, cursor: &InputCursor, width: u16) -> TestBackend { + let backend = TestBackend::new(width, 3); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| render_input(frame, frame.area(), input, cursor)) + .unwrap(); + terminal.backend().clone() + } + + #[test] + fn text_cursor_is_inserted_without_hiding_input_characters() { + let input = tui_input::Input::new("abc".to_owned()).with_cursor(1); + let backend = rendered_input(&input, &InputCursor::Text("|".to_owned()), 12); + let buffer = backend.buffer(); + + assert!(!backend.cursor_visible()); + for (x, symbol) in [(2, "a"), (3, "|"), (4, "b"), (5, "c")] { + assert_eq!(buffer[(x, 1)].symbol(), symbol); + } + assert_eq!(input.value(), "abc"); + } + + #[test] + fn text_cursor_handles_empty_and_wide_input() { + let empty = rendered_input( + &tui_input::Input::default(), + &InputCursor::Text("|".to_owned()), + 12, + ); + assert_eq!(empty.buffer()[(2, 1)].symbol(), "|"); + + let input = tui_input::Input::new("a界b".to_owned()).with_cursor(2); + let backend = rendered_input(&input, &InputCursor::Text("|".to_owned()), 12); + let buffer = backend.buffer(); + assert_eq!(buffer[(2, 1)].symbol(), "a"); + assert_eq!(buffer[(3, 1)].symbol(), "界"); + assert_eq!(buffer[(5, 1)].symbol(), "|"); + assert_eq!(buffer[(6, 1)].symbol(), "b"); + } + + #[test] + fn text_cursor_remains_visible_at_the_right_edge() { + let input = tui_input::Input::new("abcde".to_owned()); + let backend = rendered_input(&input, &InputCursor::Text("|".to_owned()), 8); + let buffer = backend.buffer(); + for (x, symbol) in [(2, "c"), (3, "d"), (4, "e"), (5, "|")] { + assert_eq!(buffer[(x, 1)].symbol(), symbol); + } + + let wide_input = tui_input::Input::new("ab界c".to_owned()).with_cursor(3); + let backend = rendered_input(&wide_input, &InputCursor::Text("|".to_owned()), 8); + let buffer = backend.buffer(); + assert_eq!(buffer[(2, 1)].symbol(), "b"); + assert_eq!(buffer[(3, 1)].symbol(), "界"); + assert_eq!(buffer[(5, 1)].symbol(), "|"); + } + #[rstest] #[case(0x2192, "> → U+2192 RIGHTWARDS ARROW")] #[case(0x3042, "> あ U+3042 HIRAGANA LETTER A")] @@ -292,6 +365,7 @@ mod tests { &ColorTheme::default(), &UiSettings { selection_cursor: ">".to_owned(), + ..Default::default() }, ) .to_string(), diff --git a/src/ui/settings.rs b/src/ui/settings.rs index f703805..c7044d4 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -12,6 +12,16 @@ use umbra::optional; pub struct UiSettings { #[garde(custom(validate_selection_cursor))] pub selection_cursor: String, + #[garde(custom(validate_input_cursor))] + pub input_cursor: InputCursor, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum InputCursor { + #[default] + Native, + Text(String), } impl UiSettings { @@ -36,6 +46,18 @@ fn validate_selection_cursor(value: &str, _: &()) -> garde::Result { Ok(()) } +fn validate_input_cursor(value: &InputCursor, _: &()) -> garde::Result { + let InputCursor::Text(text) = value else { + return Ok(()); + }; + if text.chars().any(char::is_control) || Line::from(text.as_str()).width() != 1 { + return Err(garde::Error::new( + "input cursor text must occupy exactly one terminal cell", + )); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -50,6 +72,7 @@ mod tests { for selection_cursor in ["", ">", "▸", " "] { let settings = UiSettings { selection_cursor: selection_cursor.to_owned(), + ..Default::default() }; assert!(settings.validate().is_ok(), "{selection_cursor:?}"); @@ -61,6 +84,7 @@ mod tests { for selection_cursor in ["\n", "\u{301}", "界", ">>"] { let settings = UiSettings { selection_cursor: selection_cursor.to_owned(), + ..Default::default() }; assert!(settings.validate().is_err(), "{selection_cursor:?}"); @@ -72,10 +96,30 @@ mod tests { let hidden = UiSettings::default(); let visible = UiSettings { selection_cursor: "▸".to_owned(), + ..Default::default() }; assert_eq!(hidden.selection_marker(true), " "); assert_eq!(visible.selection_marker(false), " "); assert_eq!(visible.selection_marker(true), "▸"); } + + #[test] + fn validates_text_input_cursor_width() { + assert!(UiSettings::default().validate().is_ok()); + for text in ["|", "▏", " "] { + let settings = UiSettings { + input_cursor: InputCursor::Text(text.to_owned()), + ..Default::default() + }; + assert!(settings.validate().is_ok(), "{text:?}"); + } + for text in ["", "\n", "\u{301}", "界", "||"] { + let settings = UiSettings { + input_cursor: InputCursor::Text(text.to_owned()), + ..Default::default() + }; + assert!(settings.validate().is_err(), "{text:?}"); + } + } } From 66bdf0aa048f6ff39ef31ed40259a3d717b3e9c5 Mon Sep 17 00:00:00 2001 From: Kyosuke Fujimoto Date: Sat, 19 Sep 2026 18:11:56 +0900 Subject: [PATCH 2/2] Document search input cursor configuration --- README.md | 1 + config.schema.json | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/README.md b/README.md index 0236b4e..ff97a9e 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ bg = "#00000000" [ui] selection_cursor = "▸" +input_cursor = { text = "|" } ``` UI colors accept ANSI color names, `#RRGGBB`, or an indexed color from `0` to `255`. Glyph image colors accept `#RRGGBB` and `#RRGGBBAA`. diff --git a/config.schema.json b/config.schema.json index 55303d9..fc60952 100644 --- a/config.schema.json +++ b/config.schema.json @@ -385,6 +385,27 @@ "description": "Non-color presentation settings. Omitted properties keep their built-in defaults.", "type": "object", "properties": { + "input_cursor": { + "description": "Cursor shown in the search input. Text mode inserts a visible one-cell character at the input position; runtime validation checks its terminal width.", + "oneOf": [ + { + "const": "native" + }, + { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 1, + "pattern": "^[^\\u0000-\\u001F\\u007F]+$" + } + }, + "required": ["text"], + "additionalProperties": false + } + ], + "default": "native" + }, "selection_cursor": { "description": "The cursor shown in the reserved leading cell of selected Inspector properties, list entries, and code point entries. It must be empty or occupy exactly one terminal cell.", "type": "string",