Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
21 changes: 21 additions & 0 deletions config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
62 changes: 62 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions src/ui/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
40 changes: 40 additions & 0 deletions src/ui/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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());
}
}
100 changes: 87 additions & 13 deletions src/ui/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::{
layout,
scrollbar::{self, ViewportScrollbar},
selectable_list_line, selection_preview,
settings::UiSettings,
settings::{InputCursor, UiSettings},
theme::ColorTheme,
},
unicode::UnicodeDatabase,
Expand All @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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::*;
Expand All @@ -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")]
Expand All @@ -292,6 +365,7 @@ mod tests {
&ColorTheme::default(),
&UiSettings {
selection_cursor: ">".to_owned(),
..Default::default()
},
)
.to_string(),
Expand Down
44 changes: 44 additions & 0 deletions src/ui/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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::*;
Expand All @@ -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:?}");
Expand All @@ -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:?}");
Expand All @@ -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:?}");
}
}
}
Loading