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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
61 changes: 61 additions & 0 deletions src/display.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
pub fn display_lines(text: &str) -> Vec<String> {
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..."
);
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod config;
pub mod display;
pub mod model;
pub mod pipeout;
pub mod search;
Expand Down
3 changes: 2 additions & 1 deletion src/search.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -54,7 +55,7 @@ fn run_fzf(items: &[Item], fzf_bin: &Path) -> std::result::Result<Option<String>
item.kind().as_str(),
item.group(),
item.key_combo().unwrap_or(""),
item.desc()
single_line_preview(item.desc())
)
})
.collect::<Vec<_>>()
Expand Down
74 changes: 50 additions & 24 deletions src/ui/render.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -332,6 +333,10 @@ fn format_note_label(note_id: &str) -> String {
}
}

fn detail_description_lines(text: &str) -> Vec<Line<'static>> {
display_lines(text).into_iter().map(Line::from).collect()
}

pub fn render_to_string(
items: &[Item],
selected_id: Option<&str>,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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::<Vec<_>>()
Expand All @@ -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);
Expand All @@ -983,25 +993,31 @@ fn selected_shortcut(item: Option<&Item>) -> Option<&Shortcut> {

fn selected_detail(app: AppView<'_>) -> Vec<Line<'static>> {
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(&note.desc));
lines
}
None => vec![
Line::from("Selected: none"),
Line::from("Keep typing to narrow the list."),
Expand All @@ -1014,8 +1030,8 @@ fn selected_detail(app: AppView<'_>) -> Vec<Line<'static>> {
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};
Expand Down Expand Up @@ -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(
Expand Down
38 changes: 38 additions & 0 deletions tests/search_fzf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
);
}
30 changes: 30 additions & 0 deletions tests/ui_inline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();
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()
);
}
Loading