From bf0c5d84a419a1320dc8e253525221d6107e4117 Mon Sep 17 00:00:00 2001 From: greasycat <131315c@gmail.com> Date: Mon, 23 Mar 2026 21:53:55 -0400 Subject: [PATCH 1/2] fix(ui): handle multiline note descriptions --- AGENTS.md | 6 ++++ src/display.rs | 61 +++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/search.rs | 3 +- src/ui/render.rs | 74 ++++++++++++++++++++++++++++++--------------- tests/search_fzf.rs | 38 +++++++++++++++++++++++ tests/ui_inline.rs | 30 ++++++++++++++++++ 7 files changed, 188 insertions(+), 25 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/display.rs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5c7973e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +# Repo Notes + +## CI / Test Environment +- GitHub Actions has incomplete `fzf` and Unicode terminal support. +- Tests that depend on launching `fzf` should continue to guard on `GITHUB_ACTIONS` so local behavior is covered without making CI flaky. + diff --git a/src/display.rs b/src/display.rs new file mode 100644 index 0000000..eaa00f0 --- /dev/null +++ b/src/display.rs @@ -0,0 +1,61 @@ +pub fn display_lines(text: &str) -> Vec { + let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); + let trimmed = normalized.trim_end_matches('\n'); + + if trimmed.is_empty() { + return vec![String::new()]; + } + + trimmed.split('\n').map(str::to_owned).collect() +} + +pub fn single_line_preview(text: &str) -> String { + let lines = display_lines(text); + let first = lines.first().cloned().unwrap_or_default(); + + if lines.len() > 1 { + format!("{first}...") + } else { + first + } +} + +#[cfg(test)] +mod tests { + use super::{display_lines, single_line_preview}; + + #[test] + fn display_lines_normalize_crlf_and_preserve_blank_lines() { + assert_eq!( + display_lines("First line\r\n\r\nSecond line\rThird line"), + vec![ + "First line".to_owned(), + String::new(), + "Second line".to_owned(), + "Third line".to_owned(), + ] + ); + } + + #[test] + fn display_lines_trim_trailing_line_endings() { + assert_eq!(display_lines("First line\n"), vec!["First line".to_owned()]); + assert_eq!( + display_lines("First line\r\n\r\n"), + vec!["First line".to_owned()] + ); + } + + #[test] + fn single_line_preview_uses_first_line_and_marks_hidden_content() { + assert_eq!(single_line_preview("First line"), "First line"); + assert_eq!( + single_line_preview("First line\nSecond line"), + "First line..." + ); + assert_eq!( + single_line_preview("First line\r\n\r\nSecond line"), + "First line..." + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index dd12edf..ea80934 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod config; +pub mod display; pub mod model; pub mod pipeout; pub mod search; diff --git a/src/search.rs b/src/search.rs index fe259c7..43b1310 100644 --- a/src/search.rs +++ b/src/search.rs @@ -1,4 +1,5 @@ use crate::config::DEFAULT_FZF_LAYOUT; +use crate::display::single_line_preview; use crate::model::{Item, ItemKind}; use anyhow::{Result, anyhow}; use std::io::{ErrorKind, Write}; @@ -54,7 +55,7 @@ fn run_fzf(items: &[Item], fzf_bin: &Path) -> std::result::Result item.kind().as_str(), item.group(), item.key_combo().unwrap_or(""), - item.desc() + single_line_preview(item.desc()) ) }) .collect::>() diff --git a/src/ui/render.rs b/src/ui/render.rs index 6d393cd..91ddfa8 100644 --- a/src/ui/render.rs +++ b/src/ui/render.rs @@ -1,4 +1,5 @@ use crate::config::{APP_TITLE, KeyboardCell}; +use crate::display::{display_lines, single_line_preview}; use crate::model::{AppView, Item, ItemKind, Shortcut}; use crate::pipeout; use crate::ui::keyboard::build_keyboard_lines_with_layout; @@ -332,6 +333,10 @@ fn format_note_label(note_id: &str) -> String { } } +fn detail_description_lines(text: &str) -> Vec> { + display_lines(text).into_iter().map(Line::from).collect() +} + pub fn render_to_string( items: &[Item], selected_id: Option<&str>, @@ -836,13 +841,18 @@ fn draw_app( let area = outer.inner(frame.area()); frame.render_widget(outer, frame.area()); + let detail_lines = selected_detail(app); + let [filter_area, content_area] = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(area); let [list_area, right_area] = Layout::horizontal([Constraint::Percentage(38), Constraint::Percentage(62)]) .areas(content_area); + let detail_height = (detail_lines.len() as u16) + .saturating_add(2) + .clamp(6, right_area.height.saturating_sub(6).max(6)); let [detail_area, keyboard_area] = - Layout::vertical([Constraint::Length(6), Constraint::Min(0)]).areas(right_area); + Layout::vertical([Constraint::Length(detail_height), Constraint::Min(0)]).areas(right_area); let group_width = (group_label.chars().count().max(group_query.chars().count()) as u16) .saturating_add(4) @@ -946,7 +956,7 @@ fn draw_app( }; ListItem::new(Line::from(vec![ Span::styled(lead, lead_style), - Span::raw(item.desc().to_owned()), + Span::raw(single_line_preview(item.desc())), ])) }) .collect::>() @@ -960,7 +970,7 @@ fn draw_app( } } - let detail = Paragraph::new(selected_detail(app)) + let detail = Paragraph::new(detail_lines) .wrap(Wrap { trim: false }) .block(Block::default().title(" Selection ").borders(Borders::ALL)); frame.render_widget(detail, detail_area); @@ -983,25 +993,31 @@ fn selected_shortcut(item: Option<&Item>) -> Option<&Shortcut> { fn selected_detail(app: AppView<'_>) -> Vec> { match app.selected() { - Some(Item::Shortcut(shortcut)) => vec![ - Line::from(vec![ - Span::styled("Selected: ", Style::default().add_modifier(Modifier::BOLD)), - Span::styled(shortcut.id.clone(), Style::default().fg(Color::Yellow)), - ]), - Line::from(format!("Type: shortcut")), - Line::from(format!("Group: {}", shortcut.group)), - Line::from(format!("Key: {}", shortcut.key)), - Line::from(shortcut.desc.clone()), - ], - Some(Item::Note(note)) => vec![ - Line::from(vec![ - Span::styled("Selected: ", Style::default().add_modifier(Modifier::BOLD)), - Span::styled(note.id.clone(), Style::default().fg(Color::Yellow)), - ]), - Line::from("Type: note"), - Line::from(format!("Group: {}", note.group)), - Line::from(note.desc.clone()), - ], + Some(Item::Shortcut(shortcut)) => { + let mut lines = vec![ + Line::from(vec![ + Span::styled("Selected: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(shortcut.id.clone(), Style::default().fg(Color::Yellow)), + ]), + Line::from("Type: shortcut"), + Line::from(format!("Group: {}", shortcut.group)), + Line::from(format!("Key: {}", shortcut.key)), + ]; + lines.extend(detail_description_lines(&shortcut.desc)); + lines + } + Some(Item::Note(note)) => { + let mut lines = vec![ + Line::from(vec![ + Span::styled("Selected: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(note.id.clone(), Style::default().fg(Color::Yellow)), + ]), + Line::from("Type: note"), + Line::from(format!("Group: {}", note.group)), + ]; + lines.extend(detail_description_lines(¬e.desc)); + lines + } None => vec![ Line::from("Selected: none"), Line::from("Keep typing to narrow the list."), @@ -1014,8 +1030,8 @@ fn selected_detail(app: AppView<'_>) -> Vec> { mod tests { use super::{ ALL_GROUP_LABEL, ActiveFilter, AppState, GroupSelection, SelectorAction, - calculate_viewport, format_note_label, handle_key_event, handle_selector_key_event, - handle_submit_key, should_quit, + calculate_viewport, detail_description_lines, format_note_label, handle_key_event, + handle_selector_key_event, handle_submit_key, should_quit, }; use crate::model::{Item, Note, Shortcut}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -1129,6 +1145,16 @@ mod tests { assert_eq!(format_note_label("prompt-tip"), "Prompt tip"); } + #[test] + fn detail_description_lines_split_multiline_text() { + let lines = detail_description_lines("First line\r\n\r\nSecond line\n"); + + assert_eq!(lines.len(), 3); + assert_eq!(lines[0].to_string(), "First line"); + assert_eq!(lines[1].to_string(), ""); + assert_eq!(lines[2].to_string(), "Second line"); + } + #[test] fn group_mode_with_empty_query_shows_all_items() { let items = vec![Item::Shortcut(Shortcut::new( diff --git a/tests/search_fzf.rs b/tests/search_fzf.rs index fab6caa..a07c3a1 100644 --- a/tests/search_fzf.rs +++ b/tests/search_fzf.rs @@ -102,3 +102,41 @@ fn search_items_with_fallback_does_not_invoke_fallback_on_cancel() { assert_eq!(selected, None); } + +#[test] +fn search_items_truncate_multiline_note_descriptions_for_fzf_rows() { + if should_skip_on_github_actions() { + return; + } + + let temp = tempfile::tempdir().unwrap(); + let fake_fzf = temp.path().join("fake-fzf"); + let stdin_file = temp.path().join("stdin.txt"); + fs::write( + &fake_fzf, + "#!/bin/sh\nstdin_file=\"$(dirname \"$0\")/stdin.txt\"\ncat > \"$stdin_file\"\nprintf 'note\\037shell\\037tip\\tnote\\tshell\\t\\tFirst line...\\n'", + ) + .unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&fake_fzf).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&fake_fzf, perms).unwrap(); + } + + let items = vec![Item::Note(Note::new( + "tip", + "First line\n\nSecond paragraph", + "shell", + ))]; + let selected = wkey::search::search_items(&items, fake_fzf.as_path()).unwrap(); + let stdin = fs::read_to_string(stdin_file).unwrap(); + + assert_eq!(selected.as_deref(), Some("note\u{1f}shell\u{1f}tip")); + assert_eq!( + stdin, + "note\u{1f}shell\u{1f}tip\tnote\tshell\t\tFirst line..." + ); +} diff --git a/tests/ui_inline.rs b/tests/ui_inline.rs index 0cd529a..a66c66b 100644 --- a/tests/ui_inline.rs +++ b/tests/ui_inline.rs @@ -72,3 +72,33 @@ fn render_to_string_uses_custom_keyboard_layout() { assert!(output.contains("Fn")); assert!(!output.contains("[Esc]")); } + +#[test] +fn render_to_string_preserves_multiline_note_details_and_truncates_list_preview() { + let items = vec![Item::Note(Note::new( + "tip", + "First line\n\nSecond paragraph", + "shell", + ))]; + + let output = render_to_string(&items, Some("note\u{1f}shell\u{1f}tip"), 120, 24).unwrap(); + + assert!(output.contains("First line...")); + assert!(!output.contains("First line Second paragraph")); + assert!(output.contains("Group: shell")); + assert!(output.contains("First line")); + assert!(output.contains("Second paragraph")); + + let lines = output.lines().collect::>(); + let second_paragraph_index = lines + .iter() + .position(|line| line.contains("Second paragraph")) + .unwrap(); + + assert!(lines[second_paragraph_index - 2].contains("First line")); + assert!( + lines[second_paragraph_index - 1] + .trim_matches(|ch: char| ch == '│' || ch == ' ') + .is_empty() + ); +} From 02e84d8b4a5f9b5b3ba1b179ff8ac11532153343 Mon Sep 17 00:00:00 2001 From: greasycat <131315c@gmail.com> Date: Mon, 23 Mar 2026 21:54:13 -0400 Subject: [PATCH 2/2] chore: bump version and ignore aur artifacts --- .gitignore | 2 ++ Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ad67955..7ff2113 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ target # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +aur/ +aur diff --git a/Cargo.toml b/Cargo.toml index a639dd5..b1e2d65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wkey" -version = "0.1.0" +version = "0.1.1" edition = "2024" description = "A terminal-first cheatsheet for keyboard shortcuts and working notes" readme = "README.md"