Compare commits

...

13 Commits

Author SHA1 Message Date
Alexander d1616c63bc feat: add SPC w {h,j,k,l} spatial window navigation
Pane focus follows actual screen layout: Artists (left), Albums (top-right),
Tracks (bottom-right). h/l jump between left/right columns, j/k move
between vertically stacked panes in the right column.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-10 13:37:52 +02:00
Alexander cde3fe1979 docs: add which-key widget implementation plan
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-10 13:28:27 +02:00
Alexander edf8d5b160 feat: wire which-key popup into TUI with persistent display until resolution
Popup renders after prefix key (SPC, g) and stays visible indefinitely
until sequence completes, Escape cancels, or invalid key clears state.
No timeout — matches Doom Emacs behavior where which-key waits for user.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-10 13:28:27 +02:00
Alexander 498e92f2e4 feat(which-key): add passive rendering widget crate with multi-column grid layout
Decoupled from evil-keys — accepts generic KeyHint data from any source.
Ports which-key.nvim's dimension calculation and column-major grid algorithm.
Features: unicode-aware truncation, multi-level sorting (group/alphanum/natural/case),
configurable styling via builder pattern, auto-sizing with Position-based placement.

76 tests covering dim() (31 ported from nvim), grid layout, truncation, padding, and sorting.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-10 13:28:27 +02:00
Alexander eb114fc614 docs: add evil-keys crate implementation plan
Comprehensive design document covering architecture, API surface, dispatch
algorithm, integration steps, testing strategy with 106 test cases, and
design decisions (shift normalization, conflict detection, count overflow).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-10 10:59:53 +02:00
Alexander 1c1dadf5cd feat: wire evil-keys dispatcher into event loop and action handlers
Replace 3 hardcoded keys with full trie-based dispatch. Dispatcher created
in main.rs, handle_action() implements all 18 AppAction variants including
GotoFirst/GotoLast per focus pane, NextTab/PrevTab cycling, and HalfPage scroll.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-10 10:59:43 +02:00
Alexander f859c40eb1 feat: add AppAction enum and Doom Emacs keybinding configuration
18-variant AppAction enum + build_normal_keymap() with hjkl, gg/G, gt/gT,
1-6 tabs, C-d/C-u, SPC leader tree (buffer/help/quit/notifications).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-10 10:59:43 +02:00
Alexander bee1f82405 test(evil-keys): add integration and property-based tests
20 integration scenarios (5gg, SPC leader, escape progression, timeout,
mode switch, which-key, stress tests) and 8 proptest invariants (roundtrip,
never-panic, non-Press ignored, count >= 1, escape clears state).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-10 10:59:43 +02:00
Alexander 216a11b9db feat(evil-keys): add keybinding crate with trie dispatch, count prefix, and timeout
Plug'n'play modal keybinding system inspired by Doom Emacs + Evil mode.
Generic over consumer Action type. Core: Key parser ("C-d", "SPC"),
trie-based sequence matching with conflict detection, count prefix (5j),
timeout tracking, which-key introspection, and multi-mode dispatch.

78 unit tests covering key parsing, trie conflicts, dispatch state machine,
count accumulation, timeout expiry, and which-key generation.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-10 10:59:43 +02:00
Alexander 5a34fafd3f style: apply rustfmt to existing files
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-05-10 10:59:43 +02:00
Alexander 85093d0ff0 fix: update album counters from GetAlbum response
GetArtists returns albums with total_tracks=0 and no release info.
When GetAlbum response arrives with richer data, update the album
in the artist list (have/total/status/quality/monitored). Also fall
back to release.track_count when total_tracks is 0 in the proto.

Updates proto to match new API: GetAlbumResponse.info wrapper,
AlbumDetail.release field, ReleaseInfo struct, flat TrackDetail
file fields. Passes album download state to convert_track so
tracks show as owned when album download is completed.
2026-05-09 23:31:39 +02:00
Alexander 1232b76fff fix: match 'completed' download state and prefer format over quality
The API returns download.state='completed' but convert_album only
matched 'downloaded', causing monitored+downloaded albums to display
as Wanted with 0 tracks. Also prefer download.format ('FLAC') over
download.quality (empty) for the quality display.
2026-05-09 23:23:43 +02:00
Alexander 7a35958c42 feat: separate albums/tracks into bordered panes, fix click handling
- Replace single Detail pane with separate Albums and Tracks panes,
  each with its own border that highlights yellow when focused
- Store rendered Rect areas on LibraryState instead of hardcoding
  layout offsets, fixing album click selection
- Split render functions into public components for isolated testing
- Restructure snapshot tests by component (artists, albums, tracks,
  header, detail) — 13 tests expanded to 26
2026-05-09 19:42:48 +02:00
56 changed files with 6895 additions and 287 deletions
File diff suppressed because it is too large Load Diff
+791
View File
@@ -0,0 +1,791 @@
# which-key Widget: Ratatui Rendering Crate
## Goal
Workspace member crate (`crates/which-key/`) providing a passive, decoupled which-key popup widget for ratatui. Accepts generic key-description data from ANY source — not coupled to `evil-keys` or any specific keybinding library. Implements ratatui's `Widget` trait. Ports the multi-column grid layout algorithm from which-key.nvim.
---
## Design Principles
1. **Passive** — does not define, dispatch, or intercept keybindings (like Emacs/Neovim which-key)
2. **Decoupled** — accepts `Vec<KeyHint>` via `impl IntoIterator<Item = KeyHint>`, no dependency on `evil-keys`
3. **ratatui-native** — implements `Widget` trait, composes with `Block`, `Style`, `Clear`
4. **Minimal** — only rendering + layout logic, no event handling, no timers, no state machine
5. **Own data**`WhichKey` owns its hints (sorts once at construction, no per-frame allocation)
6. **Separate layout from render**`layout()` computes Rect, `render()` draws into it
---
## Public API Surface
```rust
use which_key::{WhichKey, KeyHint, Position};
// 1. Build data from ANY source
let hints = vec![
KeyHint::new("b", "+buffer").group(),
KeyHint::new("h", "help"),
KeyHint::new("q", "quit"),
KeyHint::new("n", "notifications"),
];
// 2. Create widget (owns + sorts hints at construction time)
let popup = WhichKey::new(hints) // takes ownership, sorts immediately
.title("SPC")
.separator("")
.group_prefix("+")
.position(Position::BottomLeft)
.column_spacing(3)
.min_column_width(20)
.max_rows(15)
.key_style(Style::default().fg(Color::Yellow).bold())
.separator_style(Style::default().fg(Color::DarkGray))
.desc_style(Style::default().fg(Color::White))
.group_style(Style::default().fg(Color::Cyan))
.border_style(Style::default().fg(Color::Blue))
.bg(Color::Black);
// 3. Compute layout (separate from render — caller knows the Rect)
let popup_rect = popup.layout(frame.area());
// 4. Render into computed Rect
frame.render_widget(&popup, popup_rect);
```
### Integration with evil-keys (consumer-side, NOT in this crate)
```rust
// In ui-agregator's renderer — bridge evil-keys data to which-key widget
if let Some(entries) = dispatcher.which_key_entries() {
let hints: Vec<KeyHint> = entries
.iter()
.map(|e| {
let mut h = KeyHint::new(&e.key, &e.description);
if e.is_group { h = h.group(); }
h
})
.collect();
let popup = WhichKey::new(hints)
.title(&dispatcher.pending_display());
let popup_rect = popup.layout(frame.area());
frame.render_widget(&popup, popup_rect);
}
```
---
## Crate Structure
```
crates/which-key/
Cargo.toml
src/
lib.rs Public re-exports
hint.rs KeyHint data type (input to widget)
layout.rs Dimension calculation + multi-column grid algorithm
render.rs Widget impl — formats entries, renders to Buffer
sort.rs Multi-level sort (group, alphanum, case, natural)
```
### Dependencies
```toml
[package]
name = "which-key"
version = "0.1.0"
edition = "2021"
[dependencies]
ratatui = "0.29"
unicode-width = "0.2"
[dev-dependencies]
proptest = "1.4"
insta = "1.40.0"
```
No `crossterm`. No `evil-keys`. No `tokio`. Pure rendering.
---
## Core Types
### hint.rs
```rust
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeyHint {
pub key: String,
pub description: String,
pub is_group: bool,
}
impl KeyHint {
pub fn new(key: impl Into<String>, desc: impl Into<String>) -> Self {
Self {
key: key.into(),
description: desc.into(),
is_group: false,
}
}
pub fn group(mut self) -> Self {
self.is_group = true;
self
}
}
```
### layout.rs
```rust
use std::borrow::Cow;
use unicode_width::UnicodeWidthStr;
/// Dimension calculation — ported from which-key.nvim layout.lua.
/// Internal only (pub(crate)).
///
/// Rules:
/// |size| < 1.0 → percentage of parent (0.5 = 50%)
/// size < 0 → parent + size (subtract from parent)
/// Constraints applied after: min, max (can be percentages too)
/// Final: floor(clamp(0, parent, result) + 0.5)
pub(crate) fn dim(size: f64, parent: usize, constraints: &[DimConstraint]) -> usize;
/// Internal constraint type (not exposed publicly — avoids conflict with ratatui::layout::Constraint)
pub(crate) enum DimConstraint {
Fixed(f64),
Range { min: Option<f64>, max: Option<f64> },
}
/// Multi-column grid layout calculation.
///
/// Parameters:
/// - item_count: number of entries to display
/// - max_entry_width: widest entry in display columns (computed by caller)
/// - container_width: available terminal width
/// - min_column_width: minimum width per column (default 20)
/// - spacing: spaces between columns (default 3)
///
/// Returns None if item_count == 0 or container_width == 0.
pub fn grid_layout(
item_count: usize,
max_entry_width: usize,
container_width: usize,
min_column_width: usize,
spacing: usize,
) -> Option<GridLayout>;
pub struct GridLayout {
pub columns: usize,
pub rows: usize,
pub column_width: usize,
}
impl GridLayout {
/// Convert (column, row) to item index (column-major order).
/// Returns None if index exceeds item_count.
pub fn item_index(&self, col: usize, row: usize, item_count: usize) -> Option<usize> {
let idx = col * self.rows + row;
if idx < item_count { Some(idx) } else { None }
}
}
/// Truncate string to fit within max_width display columns.
/// Uses unicode-width for correct CJK/emoji handling.
/// Appends ellipsis "…" if truncated.
/// Returns Cow::Borrowed when no truncation needed.
pub fn truncate(s: &str, max_width: usize) -> Cow<'_, str>;
/// Pad string to exact display width with given alignment.
/// Uses ratatui's Alignment enum.
/// If content is wider than width, returns content unchanged.
pub fn pad(s: &str, width: usize, align: ratatui::layout::Alignment) -> String;
```
### sort.rs
```rust
use crate::hint::KeyHint;
/// Sort hints in-place with multi-level comparator.
/// Order: groups first → alphanumeric before special → natural number sort → lowercase before uppercase.
/// Configurable via sort_fields parameter.
pub fn sort_hints(hints: &mut [KeyHint], fields: &[SortField]);
/// Default sort order.
pub fn default_sort_order() -> Vec<SortField> {
vec![SortField::Group, SortField::Alphanum, SortField::Natural, SortField::Case]
}
#[derive(Clone, Copy, Debug)]
pub enum SortField {
Group, // groups after non-groups
Alphanum, // alphanumeric keys before special keys (<C-x>, <Esc>)
Natural, // natural number sort (F2 before F10)
Case, // lowercase before uppercase
}
```
### render.rs
```rust
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Padding, Widget},
};
use crate::hint::KeyHint;
use crate::layout::{grid_layout, truncate, pad};
use crate::sort::{sort_hints, default_sort_order, SortField};
#[derive(Clone, Copy, Debug, Default)]
pub enum Position {
#[default]
BottomLeft,
BottomRight,
BottomCenter,
TopLeft,
TopRight,
TopCenter,
Center,
}
pub struct WhichKey {
hints: Vec<KeyHint>, // owned, sorted at construction
title: Option<String>,
position: Position,
separator: String,
group_prefix: String,
column_spacing: usize,
min_column_width: usize,
max_rows: usize, // overflow protection (default: 20)
padding: Padding, // ratatui's Padding type
key_style: Style,
separator_style: Style,
desc_style: Style,
group_style: Style,
border_style: Style,
bg: Option<Color>,
sort_fields: Vec<SortField>,
}
impl WhichKey {
/// Create widget. Takes ownership of hints, sorts immediately.
pub fn new(hints: impl IntoIterator<Item = KeyHint>) -> Self {
let mut hints: Vec<KeyHint> = hints.into_iter().collect();
let fields = default_sort_order();
sort_hints(&mut hints, &fields);
Self {
hints,
title: None,
position: Position::default(),
separator: "".to_string(),
group_prefix: "+".to_string(),
column_spacing: 3,
min_column_width: 20,
max_rows: 20,
padding: Padding::new(2, 2, 1, 1), // left, right, top, bottom
key_style: Style::default(),
separator_style: Style::default(),
desc_style: Style::default(),
group_style: Style::default(),
border_style: Style::default(),
bg: None,
sort_fields: fields,
}
}
// Builder methods (all return Self):
// title, position, separator, group_prefix,
// column_spacing, min_column_width, max_rows, padding,
// key_style, separator_style, desc_style, group_style,
// border_style, bg, sort_fields
/// Compute the popup Rect given available screen area.
/// Separate from render — caller can use this Rect for hit testing or composition.
pub fn layout(&self, available: Rect) -> Rect {
if self.hints.is_empty() {
return Rect::default();
}
// 1. Compute max_entry_width from sorted hints
// 2. Compute grid layout (columns, rows, column_width)
// 3. Clamp rows to max_rows
// 4. Calculate popup dimensions (content + padding + border)
// 5. Position within available area based on self.position
// 6. Return computed Rect
}
}
impl Widget for &WhichKey {
fn render(self, area: Rect, buf: &mut Buffer) {
if self.hints.is_empty() || area.width == 0 || area.height == 0 {
return;
}
// 1. Clear area (overlay)
// 2. Render Block (border + title)
// 3. Compute grid layout for inner area
// 4. Format and render entries in column-major grid order with styles
}
}
```
---
## Algorithms
### Dimension Calculation (ported from which-key.nvim `layout.dim`, pub(crate))
```
dim(size, parent, constraints):
1. if |size| < 1.0: size = parent_f64 * size // percentage
2. if size < 0: size = parent_f64 + size // subtract
3. for each constraint:
if Fixed(v): size = dim(v, parent, []) // recursive
if Range{min, max}:
min_val = min.map(|m| dim(m, parent, [])).unwrap_or(0)
max_val = max.map(|m| dim(m, parent, [])).unwrap_or(parent)
size = clamp(size, min_val, max_val)
4. return floor(clamp(size, 0, parent_f64) + 0.5) as usize // round
```
### Multi-Column Grid Layout (ported from which-key.nvim `view.lua`)
```
grid_layout(item_count, max_entry_width, container_width, min_column_width, spacing):
if item_count == 0 || container_width == 0: return None
1. box_width = dim(max_entry_width, container_width, Range{min: min_column_width})
2. columns = max(floor(container_width / (box_width + spacing)), 1)
3. column_width = floor(container_width / columns)
4. rows = max(ceil(item_count / columns), 1)
5. return Some(GridLayout { columns, rows, column_width })
Item index (column-major):
index(col, row) = col * rows + row
Returns None if index >= item_count
```
### max_entry_width Calculation (in render.rs, before calling grid_layout)
```
max_entry_width(hints, separator, group_prefix):
max over all hints of:
display_width(hint.key) + display_width(" {separator} ") + display_width(desc_with_prefix)
where desc_with_prefix = if hint.is_group: "{group_prefix}{desc}" else: desc
```
### Entry Formatting
```
format_entry(hint, key_width, column_width):
key_part = pad(hint.key, key_width, Align::Right) // right-aligned
sep_part = " {separator} "
desc_text = if hint.is_group: "{group_prefix}{hint.description}" else: hint.description
desc_part = truncate(desc_text, column_width - key_width - sep_width)
desc_part = pad(desc_part, remaining_width, Align::Left)
return [
Span(key_part, key_style),
Span(sep_part, separator_style),
Span(desc_part, if hint.is_group: group_style else: desc_style),
]
```
### Popup Positioning
```
position_popup(area, popup_width, popup_height, position):
match position:
BottomLeft: (area.x, area.bottom() - popup_height)
BottomRight: (area.right() - popup_width, area.bottom() - popup_height)
BottomCenter: (area.x + (area.width - popup_width) / 2, area.bottom() - popup_height)
TopLeft: (area.x, area.y)
TopRight: (area.right() - popup_width, area.y)
TopCenter: (area.x + (area.width - popup_width) / 2, area.y)
Center: centered(area, popup_width, popup_height)
```
---
## Integration into ui-agregator
### Step 1: Add dependency
```toml
# Root Cargo.toml
[workspace]
members = ["crates/evil-keys", "crates/which-key"]
# ui-agregator dependencies
[dependencies]
which-key = { path = "crates/which-key" }
```
### Step 2: Bridge evil-keys → which-key (in ui-agregator, not in either crate)
```rust
// src/presentation/which_key_popup.rs
use evil_keys::WhichKeyEntry;
use which_key::{KeyHint, WhichKey};
pub fn to_hints(entries: &[WhichKeyEntry]) -> Vec<KeyHint> {
entries
.iter()
.map(|e| {
let mut h = KeyHint::new(&e.key, &e.description);
if e.is_group { h = h.group(); }
h
})
.collect()
}
```
### Step 3: Render in app_renderer.rs
```rust
// After modal rendering, before notifications
if let Some(entries) = dispatcher.which_key_entries() {
let hints = to_hints(&entries);
let popup = WhichKey::new(hints)
.title(&dispatcher.pending_display())
.position(Position::BottomLeft)
.key_style(Style::default().fg(theme::YELLOW).add_modifier(Modifier::BOLD))
.separator_style(Style::default().fg(theme::GRAY))
.desc_style(Style::default().fg(theme::FG2))
.group_style(Style::default().fg(theme::AQUA))
.border_style(Style::default().fg(theme::BG3))
.bg(theme::BG0);
let popup_rect = popup.layout(frame.area());
frame.render_widget(&popup, popup_rect);
}
```
### Step 4: Trigger in main.rs
```rust
// Already done: dispatcher.pending_elapsed() and check_timeout()
// The renderer just checks dispatcher.which_key_entries()
// No new state needed — the popup renders when entries exist
```
---
## Implementation Phases
### Phase 1: Core crate + basic rendering
Files: `hint.rs`, `layout.rs`, `render.rs`, `lib.rs`
Skip: sort.rs (use simple groups-first + alphabetical)
Result: Which-key popup renders with correct multi-column layout
Test: layout dim() + grid_layout() + truncate() + pad()
Effort: 3-4h
### Phase 2: Sorting + polish
Files: `sort.rs`, update `render.rs`
Result: Entries sorted naturally, groups before leaves
Test: sort field combinations, natural number sort
Effort: 1-2h
### Phase 3: Wire into ui-agregator
Files: `src/presentation/which_key_popup.rs`, update `app_renderer.rs`, update `main.rs`
Result: Popup appears after pressing SPC or g and waiting 1s
Test: manual visual verification + snapshot tests
Effort: 1-2h
---
## Testing Strategy
### Dependencies
```toml
[dev-dependencies]
proptest = "1.4"
insta = "1.40.0"
```
### Test Structure
```
crates/which-key/
src/
hint.rs # #[cfg(test)] mod tests
layout.rs # #[cfg(test)] mod tests — dim() + grid + truncate + pad
sort.rs # #[cfg(test)] mod tests — sort field combinations
render.rs # #[cfg(test)] mod tests — snapshot tests with TestBackend
tests/
layout_dim.rs # Ported from which-key.nvim layout_spec.lua (31 tests)
grid_layout.rs # Multi-column grid scenarios
render_snapshots.rs # Visual output verification via insta
proptest_layout.rs # Property-based invariants
```
### 1. Dimension Calculation — Ported from which-key.nvim (31 tests)
```rust
// Direct port of layout_spec.lua
dim(100.0, 200, &[]) == 100
dim(0.2, 100, &[]) == 20
dim(-0.2, 100, &[]) == 80
dim(-20.0, 100, &[]) == 80
dim(1.0, 100, &[]) == 1
dim(100.0, 200, &[Range{min: Some(50.0), ..}]) == 100
dim(100.0, 200, &[Range{max: Some(150.0),..}]) == 100
dim(100.0, 200, &[Range{min:50, max:150}]) == 100
dim(100.0, 200, &[Range{min:150, max:150}]) == 150
dim(0.2, 100, &[Range{min:20, max:150}]) == 20
dim(0.2, 100, &[Range{min:20, max:50}]) == 20
dim(f64::MAX, 200, &[]) == 200 // infinity clamped
dim(-0.5, 200, &[]) == 100
dim(0.5, 200, &[]) == 100
dim(0.5, 200, &[Range{min:150, ..}]) == 150
dim(-0.5, 200, &[Range{max:50, ..}]) == 50
dim(300.0, 200, &[Range{max:250, ..}]) == 200 // clamped to parent
dim(300.0, 200, &[Range{min:250, ..}]) == 200
dim(-100.0, 100, &[Range{min:20, max:90}]) == 20
dim(-200.0, 100, &[Range{min:-50, max:-50}]) == 0
dim(0.2, 100, &[Range{min:0.5, ..}]) == 50 // percentage min
dim(-200.0, 100, &[]) == 0
dim(-1.0, 100, &[]) == 99
dim(-0.1, 100, &[]) == 90
dim(0.1, 100, &[]) == 10
dim(14.0, 212, &[Fixed(0.9)]) == 191 // constraint as percentage
```
### 2. Grid Layout Tests
```rust
// === Basic grid calculation ===
// grid_layout(item_count, max_entry_width, container_width, min_column_width, spacing)
grid_layout(6, 20, 80, 20, 3) -> Some(GridLayout { columns: 3, rows: 2, column_width: 26 })
grid_layout(1, 20, 80, 20, 3) -> Some(GridLayout { columns: 1, rows: 1, column_width: 80 })
grid_layout(10, 30, 40, 20, 3) -> Some(GridLayout { columns: 1, rows: 10, column_width: 40 })
grid_layout(0, 20, 80, 20, 3) -> None // empty
grid_layout(5, 20, 0, 20, 3) -> None // zero container width
// === Column-major indexing ===
// 6 items, 2 columns, 3 rows:
// Item 0 Item 3
// Item 1 Item 4
// Item 2 Item 5
grid.item_index(0, 0, 6) == Some(0)
grid.item_index(0, 1, 6) == Some(1)
grid.item_index(0, 2, 6) == Some(2)
grid.item_index(1, 0, 6) == Some(3)
grid.item_index(1, 1, 6) == Some(4)
grid.item_index(1, 2, 6) == Some(5)
grid.item_index(2, 0, 6) == None // out of bounds
// === Uneven last column ===
// 5 items, 2 columns, 3 rows:
// Item 0 Item 3
// Item 1 Item 4
// Item 2 (empty)
grid.item_index(1, 2, 5) == None
// === Narrow terminal ===
grid_layout(10, 20, 15, 20, 3) -> Some(columns: 1) // can't fit 2 columns
// === Wide terminal ===
grid_layout(3, 20, 200, 20, 3) -> Some(columns: 3, rows: 1)
```
### 3. Truncation Tests
```rust
truncate("hello world", 11) == "hello world" // fits exactly
truncate("hello world", 10) == "hello wo…" // truncated
truncate("hello world", 5) == "hel…" // short
truncate("hello world", 1) == "" // minimal
truncate("hello world", 0) == "" // zero width
truncate("", 10) == "" // empty input
truncate("🔥🔥🔥", 5) == "🔥🔥…" // emoji (each 2 wide)
truncate("あいう", 5) == "あい…" // CJK (each 2 wide)
truncate("aあb", 4) == "aあ…" // mixed widths
truncate("hello", 100) == "hello" // width > content
```
### 4. Padding Tests
```rust
pad("hi", 6, Align::Left) == "hi "
pad("hi", 6, Align::Right) == " hi"
pad("hi", 6, Align::Center) == " hi "
pad("hi", 7, Align::Center) == " hi " // odd: extra space on right
pad("hi", 2, Align::Left) == "hi" // exact fit
pad("hi", 1, Align::Left) == "hi" // width < content: no truncation
pad("", 5, Align::Left) == " " // empty string
pad("🔥", 4, Align::Left) == "🔥 " // emoji width = 2
```
### 5. Sort Tests
```rust
// === Groups before leaves ===
sort [("z", leaf), ("a", group), ("m", leaf)]
-> [("a", group), ("m", leaf), ("z", leaf)]
// === Alphanum before special ===
sort [("<C-d>", leaf), ("j", leaf), ("<Esc>", leaf)]
-> [("j", leaf), ("<C-d>", leaf), ("<Esc>", leaf)]
// === Natural number sort ===
sort [("F10", leaf), ("F2", leaf), ("F1", leaf)]
-> [("F1", leaf), ("F2", leaf), ("F10", leaf)]
// === Case: lowercase before uppercase ===
sort [("G", leaf), ("g", leaf)]
-> [("g", leaf), ("G", leaf)]
// === Combined default sort ===
sort [("G", leaf), ("b", group), ("a", leaf), ("1", leaf), ("<C-d>", leaf)]
-> [("b", group), ("1", leaf), ("a", leaf), ("G", leaf), ("<C-d>", leaf)]
```
### 6. Render Snapshot Tests (via insta + ratatui TestBackend)
```rust
#[test]
fn snapshot_basic_popup() {
let hints = vec![
KeyHint::new("b", "+buffer").group(),
KeyHint::new("h", "help"),
KeyHint::new("q", "quit"),
];
let widget = WhichKey::new(hints).title("SPC");
let area = Rect::new(0, 0, 40, 10);
let popup_rect = widget.layout(area);
let mut buf = Buffer::empty(area);
(&widget).render(popup_rect, &mut buf);
insta::assert_snapshot!(buf_to_string(&buf));
}
#[test]
fn snapshot_multi_column() { /* 10+ items forcing 2-3 columns */ }
#[test]
fn snapshot_wide_terminal() { /* 200 width, few items */ }
#[test]
fn snapshot_narrow_terminal() { /* 20 width, forces single column */ }
#[test]
fn snapshot_empty_hints() { /* empty vec → renders nothing (Rect::default()) */ }
#[test]
fn snapshot_single_hint() { /* one item */ }
#[test]
fn snapshot_long_descriptions() { /* descriptions that get truncated */ }
#[test]
fn snapshot_unicode_keys() { /* emoji/CJK keys */ }
```
### 7. Property-Based Tests
```rust
proptest! {
// dim() always returns 0..=parent
fn dim_always_within_bounds(size in -1000.0..1000.0f64, parent in 1usize..500) {
let result = dim(size, parent, &[]);
assert!(result <= parent);
}
// grid_layout columns * rows >= item_count (when Some)
fn grid_covers_all_items(count in 1usize..100, entry_w in 5usize..50, width in 20usize..200) {
if let Some(grid) = grid_layout(count, entry_w, width, 20, 3) {
assert!(grid.columns * grid.rows >= count);
}
}
// truncate never exceeds max_width
fn truncate_respects_width(s in "[a-zA-Z0-9 ]{0,50}", width in 0usize..60) {
let result = truncate(&s, width);
assert!(UnicodeWidthStr::width(result.as_ref()) <= width);
}
// pad produces exact target width (when target >= content width)
fn pad_exact_width(s in "[a-z]{0,10}", extra in 0usize..20) {
let content_width = UnicodeWidthStr::width(s.as_str());
let width = content_width + extra;
let result = pad(&s, width, Alignment::Left);
assert_eq!(UnicodeWidthStr::width(result.as_str()), width);
}
// sort is stable (equal elements preserve order)
fn sort_is_stable(hints in vec(key_hint_strategy(), 0..20)) {
let mut sorted = hints.clone();
sort_hints(&mut sorted, &default_sort_order());
// verify: if two hints compare equal on all sort fields, their relative order is preserved
}
// WhichKey::layout never panics and always returns rect within available
fn layout_within_bounds(count in 0usize..50, width in 0u16..200, height in 0u16..50) {
let hints: Vec<KeyHint> = (0..count)
.map(|i| KeyHint::new(format!("{}", (b'a' + (i as u8 % 26)) as char), "desc"))
.collect();
let widget = WhichKey::new(hints);
let available = Rect::new(0, 0, width, height);
let result = widget.layout(available);
assert!(result.x + result.width <= available.width);
assert!(result.y + result.height <= available.height);
}
}
```
### 8. Corner Cases
```rust
// === Empty input ===
WhichKey::new(vec![]).layout(area) == Rect::default() // zero-size rect
// render on Rect::default() is no-op
// === All groups, no leaves ===
// Should render all with group_prefix
// === All leaves, no groups ===
// Should render all without prefix
// === Very long key (wider than column) ===
KeyHint::new("SPC b l w q h n p", "deeply nested") // key itself exceeds column width → truncated
// === Description with special characters ===
KeyHint::new("a", "description with → and … and 🔥")
// === Zero-width terminal ===
WhichKey::new(hints).layout(Rect::new(0, 0, 0, 0)) == Rect::default()
// render is no-op
// === 1x1 terminal ===
WhichKey::new(hints).layout(Rect::new(0, 0, 1, 1)) // returns minimal rect, no crash
// render draws what fits (likely nothing visible)
// === Duplicate keys ===
vec![KeyHint::new("a", "first"), KeyHint::new("a", "second")] // both rendered
// === 100+ items with max_rows ===
// With .max_rows(15): shows first 15, truncates rest
// Without: shows all in multi-column (may exceed screen → clamped to available height)
// === Overflow clamping ===
// If computed popup_height > available.height: clamp to available.height
// If computed popup_width > available.width: clamp to available.width
```
---
## What NOT to Build
- Event handling (the consumer handles keys)
- Timer/timeout (the consumer decides when to show/hide)
- Keybinding introspection (consumer provides data)
- Scrolling (popup should fit; add in v2 if needed)
- Breadcrumb trail (add in v2; consumer can put it in title for now)
- Animation (unnecessary for TUI)
- Configuration files (styles passed via builder)
- Dependency on `evil-keys` or `crossterm`
Generated
+158 -5
View File
@@ -151,6 +151,21 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bitflags"
version = "2.11.1"
@@ -329,6 +344,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "evil-keys"
version = "0.1.0"
dependencies = [
"crossterm",
"indexmap 2.14.0",
"proptest",
]
[[package]]
name = "eyre"
version = "0.6.12"
@@ -413,6 +437,18 @@ dependencies = [
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.2"
@@ -421,7 +457,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
@@ -780,6 +816,15 @@ dependencies = [
"libc",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "object"
version = "0.37.3"
@@ -900,6 +945,25 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "proptest"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set",
"bit-vec",
"bitflags",
"num-traits",
"rand 0.9.4",
"rand_chacha 0.9.0",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
"tempfile",
"unarray",
]
[[package]]
name = "prost"
version = "0.13.5"
@@ -952,6 +1016,12 @@ dependencies = [
"prost",
]
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quote"
version = "1.0.45"
@@ -961,6 +1031,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
@@ -974,8 +1050,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
[[package]]
@@ -985,7 +1071,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
@@ -997,6 +1093,24 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "ratatui"
version = "0.29.0"
@@ -1094,6 +1208,18 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rusty-fork"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error",
"tempfile",
"wait-timeout",
]
[[package]]
name = "ryu"
version = "1.0.23"
@@ -1424,7 +1550,7 @@ dependencies = [
"indexmap 1.9.3",
"pin-project",
"pin-project-lite",
"rand",
"rand 0.8.6",
"slab",
"tokio",
"tokio-util",
@@ -1524,6 +1650,7 @@ version = "0.1.0"
dependencies = [
"color-eyre",
"crossterm",
"evil-keys",
"insta",
"nix",
"prost",
@@ -1533,8 +1660,15 @@ dependencies = [
"tokio",
"tonic",
"tonic-build",
"which-key",
]
[[package]]
name = "unarray"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -1588,6 +1722,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "want"
version = "0.3.1"
@@ -1655,6 +1798,16 @@ dependencies = [
"semver",
]
[[package]]
name = "which-key"
version = "0.1.0"
dependencies = [
"insta",
"proptest",
"ratatui",
"unicode-width 0.2.0",
]
[[package]]
name = "winapi"
version = "0.3.9"
+5
View File
@@ -1,3 +1,6 @@
[workspace]
members = ["crates/evil-keys", "crates/which-key"]
[package]
name = "ui-agregator"
version = "0.1.0"
@@ -10,6 +13,8 @@ color-eyre = "0.6"
nix = { version = "0.29", features = ["fs"] }
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
evil-keys = { path = "crates/evil-keys" }
which-key = { path = "crates/which-key" }
tonic = "0.12"
prost = "0.13"
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "evil-keys"
version = "0.1.0"
edition = "2021"
[dependencies]
crossterm = "0.28"
indexmap = "2"
[dev-dependencies]
proptest = "1.4"
+114
View File
@@ -0,0 +1,114 @@
pub struct CountState {
digits: String,
}
impl CountState {
pub fn new() -> Self {
Self {
digits: String::new(),
}
}
pub fn push_digit(&mut self, d: char) {
self.digits.push(d);
}
pub fn take(&mut self) -> usize {
if self.digits.is_empty() {
return 1;
}
let val = self.digits.parse::<usize>().unwrap_or(usize::MAX);
self.digits.clear();
val
}
pub fn is_active(&self) -> bool {
!self.digits.is_empty()
}
pub fn display(&self) -> &str {
&self.digits
}
pub fn reset(&mut self) {
self.digits.clear();
}
}
impl Default for CountState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_take() {
let mut count = CountState::new();
assert_eq!(count.take(), 1);
}
#[test]
fn test_push_take() {
let mut count = CountState::new();
count.push_digit('5');
assert_eq!(count.take(), 5);
assert_eq!(count.take(), 1);
}
#[test]
fn test_multi_digit() {
let mut count = CountState::new();
count.push_digit('1');
count.push_digit('2');
count.push_digit('3');
assert_eq!(count.take(), 123);
}
#[test]
fn test_leading_one() {
let mut count = CountState::new();
count.push_digit('1');
count.push_digit('0');
assert_eq!(count.take(), 10);
}
#[test]
fn test_saturate() {
let mut count = CountState::new();
for _ in 0..20 {
count.push_digit('9');
}
assert_eq!(count.take(), usize::MAX);
}
#[test]
fn test_is_active() {
let mut count = CountState::new();
assert!(!count.is_active());
count.push_digit('5');
assert!(count.is_active());
count.take();
assert!(!count.is_active());
}
#[test]
fn test_display() {
let mut count = CountState::new();
assert_eq!(count.display(), "");
count.push_digit('5');
assert_eq!(count.display(), "5");
}
#[test]
fn test_reset() {
let mut count = CountState::new();
count.push_digit('5');
count.reset();
assert!(!count.is_active());
assert_eq!(count.display(), "");
}
}
+615
View File
@@ -0,0 +1,615 @@
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use std::collections::HashMap;
use std::time::Duration;
use crate::count::CountState;
use crate::error::ModeError;
use crate::key::Key;
use crate::timeout::TimeoutTracker;
use crate::trie::{KeyTrie, SearchResult};
use crate::which_key::WhichKeyEntry;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DispatchResult<A> {
Matched { action: A, count: usize },
Pending,
Cancelled,
CountAccumulated,
Ignored,
NotFound,
}
pub struct Dispatcher<A: Clone> {
modes: HashMap<String, KeyTrie<A>>,
active_mode: String,
pending: Vec<Key>,
count: CountState,
timeout: TimeoutTracker,
}
impl<A: Clone> Dispatcher<A> {
pub fn new() -> Self {
Self {
modes: HashMap::new(),
active_mode: String::new(),
pending: Vec::new(),
count: CountState::new(),
timeout: TimeoutTracker::new(Duration::from_secs(1)),
}
}
pub fn add_mode(&mut self, name: &str, keymap: KeyTrie<A>) -> Result<(), ModeError> {
self.modes.insert(name.to_string(), keymap);
Ok(())
}
pub fn set_active(&mut self, mode: &str) -> Result<(), ModeError> {
if !self.modes.contains_key(mode) {
return Err(ModeError::UnknownMode(mode.to_string()));
}
self.active_mode = mode.to_string();
self.pending.clear();
self.count.reset();
self.timeout.reset();
Ok(())
}
pub fn active_mode(&self) -> &str {
&self.active_mode
}
pub fn set_timeout(&mut self, timeout: Duration) {
self.timeout = TimeoutTracker::new(timeout);
}
pub fn dispatch(&mut self, event: KeyEvent) -> DispatchResult<A> {
if event.kind != KeyEventKind::Press {
return DispatchResult::Ignored;
}
let key = Key::from(event);
let is_escape = key.code == KeyCode::Esc && key.modifiers == KeyModifiers::NONE;
if is_escape && (!self.pending.is_empty() || self.count.is_active()) {
self.pending.clear();
self.count.reset();
self.timeout.reset();
return DispatchResult::Cancelled;
}
if let KeyCode::Char(c) = key.code {
if key.modifiers == KeyModifiers::NONE && self.pending.is_empty() {
let is_count_digit = c.is_ascii_digit() && (c != '0' || self.count.is_active());
if is_count_digit {
self.count.push_digit(c);
return DispatchResult::CountAccumulated;
}
}
}
self.pending.push(key);
let Some(trie) = self.modes.get(&self.active_mode) else {
self.pending.clear();
self.count.reset();
self.timeout.reset();
return DispatchResult::NotFound;
};
match trie.search(&self.pending) {
SearchResult::Found(leaf) => {
let action = leaf.action.clone();
let count = self.count.take();
self.pending.clear();
self.timeout.reset();
DispatchResult::Matched { action, count }
}
SearchResult::Prefix(_) => {
if !self.timeout.is_active() {
self.timeout.start();
}
DispatchResult::Pending
}
SearchResult::NotFound => {
self.pending.clear();
self.count.reset();
self.timeout.reset();
DispatchResult::NotFound
}
}
}
pub fn check_timeout(&mut self) -> bool {
if self.timeout.check() {
self.pending.clear();
self.count.reset();
return true;
}
false
}
pub fn pending_keys(&self) -> &[Key] {
&self.pending
}
pub fn pending_display(&self) -> String {
self.pending
.iter()
.map(|k| k.to_string())
.collect::<Vec<_>>()
.join(" ")
}
pub fn pending_elapsed(&self) -> Duration {
self.timeout.elapsed()
}
pub fn clear_pending(&mut self) {
self.pending.clear();
self.count.reset();
self.timeout.reset();
}
pub fn which_key_entries(&self) -> Option<Vec<WhichKeyEntry>> {
if self.pending.is_empty() {
return None;
}
let trie = self.modes.get(&self.active_mode)?;
if let SearchResult::Prefix(node) = trie.search(&self.pending) {
Some(node.which_key_entries())
} else {
None
}
}
pub fn count_display(&self) -> Option<&str> {
if self.count.is_active() {
Some(self.count.display())
} else {
None
}
}
}
impl<A: Clone> Default for Dispatcher<A> {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::trie::KeyTrie;
use crossterm::event::KeyEventState;
fn press(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn release(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: KeyEventState::NONE,
}
}
#[test]
fn test_dispatch_non_press() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let mut trie = KeyTrie::new("normal");
trie.bind("j", "down").unwrap();
disp.add_mode("normal", trie).unwrap();
disp.set_active("normal").unwrap();
assert_eq!(
disp.dispatch(release(KeyCode::Char('j'))),
DispatchResult::Ignored
);
}
#[test]
fn test_dispatch_no_modes() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
assert_eq!(
disp.dispatch(press(KeyCode::Char('j'))),
DispatchResult::NotFound
);
}
#[test]
fn test_simple_match() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let mut trie = KeyTrie::new("normal");
trie.bind("j", "down").unwrap();
disp.add_mode("normal", trie).unwrap();
disp.set_active("normal").unwrap();
assert_eq!(
disp.dispatch(press(KeyCode::Char('j'))),
DispatchResult::Matched {
action: "down",
count: 1
}
);
}
#[test]
fn test_sequence_match() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let mut trie = KeyTrie::new("normal");
trie.bind("g g", "goto_top").unwrap();
disp.add_mode("normal", trie).unwrap();
disp.set_active("normal").unwrap();
assert_eq!(
disp.dispatch(press(KeyCode::Char('g'))),
DispatchResult::Pending
);
assert_eq!(
disp.dispatch(press(KeyCode::Char('g'))),
DispatchResult::Matched {
action: "goto_top",
count: 1
}
);
}
#[test]
fn test_wrong_key_mid_sequence() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let mut trie = KeyTrie::new("normal");
trie.bind("g g", "goto_top").unwrap();
disp.add_mode("normal", trie).unwrap();
disp.set_active("normal").unwrap();
assert_eq!(
disp.dispatch(press(KeyCode::Char('g'))),
DispatchResult::Pending
);
assert_eq!(
disp.dispatch(press(KeyCode::Char('h'))),
DispatchResult::NotFound
);
assert_eq!(
disp.dispatch(press(KeyCode::Char('g'))),
DispatchResult::Pending
);
}
#[test]
fn test_escape_nothing_pending_bound() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let mut trie = KeyTrie::new("normal");
trie.bind("Esc", "escape_action").unwrap();
disp.add_mode("normal", trie).unwrap();
disp.set_active("normal").unwrap();
assert_eq!(
disp.dispatch(press(KeyCode::Esc)),
DispatchResult::Matched {
action: "escape_action",
count: 1
}
);
}
#[test]
fn test_escape_clears_pending() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let mut trie = KeyTrie::new("normal");
trie.bind("g g", "goto_top").unwrap();
disp.add_mode("normal", trie).unwrap();
disp.set_active("normal").unwrap();
assert_eq!(
disp.dispatch(press(KeyCode::Char('g'))),
DispatchResult::Pending
);
assert_eq!(
disp.dispatch(press(KeyCode::Esc)),
DispatchResult::Cancelled
);
}
#[test]
fn test_escape_clears_count() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let mut trie = KeyTrie::new("normal");
trie.bind("j", "down").unwrap();
disp.add_mode("normal", trie).unwrap();
disp.set_active("normal").unwrap();
assert_eq!(
disp.dispatch(press(KeyCode::Char('5'))),
DispatchResult::CountAccumulated
);
assert_eq!(
disp.dispatch(press(KeyCode::Esc)),
DispatchResult::Cancelled
);
}
#[test]
fn test_escape_clears_both() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let mut trie = KeyTrie::new("normal");
trie.bind("g g", "goto_top").unwrap();
trie.bind("j", "down").unwrap();
disp.add_mode("normal", trie).unwrap();
disp.set_active("normal").unwrap();
assert_eq!(
disp.dispatch(press(KeyCode::Char('3'))),
DispatchResult::CountAccumulated
);
assert_eq!(
disp.dispatch(press(KeyCode::Char('g'))),
DispatchResult::Pending
);
assert_eq!(
disp.dispatch(press(KeyCode::Esc)),
DispatchResult::Cancelled
);
assert_eq!(
disp.dispatch(press(KeyCode::Char('j'))),
DispatchResult::Matched {
action: "down",
count: 1
}
);
}
#[test]
fn test_count_zero_as_binding() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let mut trie = KeyTrie::new("normal");
trie.bind("0", "start_of_line").unwrap();
disp.add_mode("normal", trie).unwrap();
disp.set_active("normal").unwrap();
assert_eq!(
disp.dispatch(press(KeyCode::Char('0'))),
DispatchResult::Matched {
action: "start_of_line",
count: 1
}
);
}
#[test]
fn test_count_10() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let mut trie = KeyTrie::new("normal");
trie.bind("j", "down").unwrap();
disp.add_mode("normal", trie).unwrap();
disp.set_active("normal").unwrap();
assert_eq!(
disp.dispatch(press(KeyCode::Char('1'))),
DispatchResult::CountAccumulated
);
assert_eq!(
disp.dispatch(press(KeyCode::Char('0'))),
DispatchResult::CountAccumulated
);
assert_eq!(
disp.dispatch(press(KeyCode::Char('j'))),
DispatchResult::Matched {
action: "down",
count: 10
}
);
}
#[test]
fn test_count_through_prefix() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let mut trie = KeyTrie::new("normal");
trie.bind("g g", "goto_top").unwrap();
disp.add_mode("normal", trie).unwrap();
disp.set_active("normal").unwrap();
assert_eq!(
disp.dispatch(press(KeyCode::Char('5'))),
DispatchResult::CountAccumulated
);
assert_eq!(
disp.dispatch(press(KeyCode::Char('g'))),
DispatchResult::Pending
);
assert_eq!(
disp.dispatch(press(KeyCode::Char('g'))),
DispatchResult::Matched {
action: "goto_top",
count: 5
}
);
}
#[test]
fn test_digits_during_pending() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let mut trie = KeyTrie::new("normal");
trie.bind("g 3", "some_action").unwrap();
disp.add_mode("normal", trie).unwrap();
disp.set_active("normal").unwrap();
assert_eq!(
disp.dispatch(press(KeyCode::Char('g'))),
DispatchResult::Pending
);
assert_eq!(
disp.dispatch(press(KeyCode::Char('3'))),
DispatchResult::Matched {
action: "some_action",
count: 1
}
);
}
#[test]
fn test_mode_switch_clears() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let mut normal = KeyTrie::new("normal");
normal.bind("g g", "goto_top").unwrap();
let insert = KeyTrie::new("insert");
disp.add_mode("normal", normal).unwrap();
disp.add_mode("insert", insert).unwrap();
disp.set_active("normal").unwrap();
assert_eq!(
disp.dispatch(press(KeyCode::Char('g'))),
DispatchResult::Pending
);
disp.set_active("insert").unwrap();
assert!(disp.pending_keys().is_empty());
}
#[test]
fn test_which_key_entries_after_spc() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let mut trie = KeyTrie::new("normal");
trie.group("SPC", "leader", |node| {
node.bind_desc("b", "buffers", "Buffers")?;
node.bind_desc("f", "files", "Files")?;
Ok(())
})
.unwrap();
disp.add_mode("normal", trie).unwrap();
disp.set_active("normal").unwrap();
assert_eq!(
disp.dispatch(press(KeyCode::Char(' '))),
DispatchResult::Pending
);
let entries = disp.which_key_entries().unwrap();
assert!(!entries.is_empty());
}
#[test]
fn test_which_key_entries_nothing_pending() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let trie = KeyTrie::new("normal");
disp.add_mode("normal", trie).unwrap();
disp.set_active("normal").unwrap();
assert!(disp.which_key_entries().is_none());
}
#[test]
fn test_pending_display() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let mut trie = KeyTrie::new("normal");
trie.bind("SPC b l", "list_buffers").unwrap();
disp.add_mode("normal", trie).unwrap();
disp.set_active("normal").unwrap();
disp.dispatch(press(KeyCode::Char(' ')));
assert_eq!(disp.pending_display(), "SPC");
disp.dispatch(press(KeyCode::Char('b')));
assert_eq!(disp.pending_display(), "SPC b");
}
#[test]
fn test_count_display() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let mut trie = KeyTrie::new("normal");
trie.bind("j", "down").unwrap();
disp.add_mode("normal", trie).unwrap();
disp.set_active("normal").unwrap();
assert!(disp.count_display().is_none());
disp.dispatch(press(KeyCode::Char('5')));
assert_eq!(disp.count_display(), Some("5"));
}
#[test]
fn test_timeout_clears_count() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let mut trie = KeyTrie::new("normal");
trie.bind("j", "down").unwrap();
trie.bind("g g", "top").unwrap();
disp.add_mode("normal", trie).unwrap();
disp.set_active("normal").unwrap();
disp.set_timeout(std::time::Duration::from_millis(50));
assert_eq!(
disp.dispatch(press(KeyCode::Char('5'))),
DispatchResult::CountAccumulated
);
assert_eq!(
disp.dispatch(press(KeyCode::Char('g'))),
DispatchResult::Pending
);
std::thread::sleep(std::time::Duration::from_millis(80));
assert!(disp.check_timeout());
assert_eq!(
disp.dispatch(press(KeyCode::Char('j'))),
DispatchResult::Matched {
action: "down",
count: 1
}
);
}
#[test]
fn test_escape_full_progression() {
let mut disp: Dispatcher<&str> = Dispatcher::new();
let mut trie = KeyTrie::new("normal");
trie.bind("j", "down").unwrap();
trie.bind("g g", "top").unwrap();
disp.add_mode("normal", trie).unwrap();
disp.set_active("normal").unwrap();
assert_eq!(
disp.dispatch(press(KeyCode::Char('5'))),
DispatchResult::CountAccumulated
);
assert_eq!(
disp.dispatch(press(KeyCode::Esc)),
DispatchResult::Cancelled
);
assert_eq!(
disp.dispatch(press(KeyCode::Char('g'))),
DispatchResult::Pending
);
assert_eq!(
disp.dispatch(press(KeyCode::Esc)),
DispatchResult::Cancelled
);
assert_eq!(
disp.dispatch(press(KeyCode::Char('3'))),
DispatchResult::CountAccumulated
);
assert_eq!(
disp.dispatch(press(KeyCode::Char('g'))),
DispatchResult::Pending
);
assert_eq!(
disp.dispatch(press(KeyCode::Esc)),
DispatchResult::Cancelled
);
assert_eq!(
disp.dispatch(press(KeyCode::Char('j'))),
DispatchResult::Matched {
action: "down",
count: 1
}
);
}
}
+78
View File
@@ -0,0 +1,78 @@
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
EmptyInput,
UnknownKey(String),
DanglingModifier,
DuplicateModifier,
RedundantShift,
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyInput => write!(f, "empty input"),
Self::UnknownKey(k) => write!(f, "unknown key: {k}"),
Self::DanglingModifier => write!(f, "dangling modifier (e.g. \"C-\")"),
Self::DuplicateModifier => write!(f, "duplicate modifier"),
Self::RedundantShift => write!(
f,
"redundant shift on uppercase char (use \"G\" not \"S-G\")"
),
}
}
}
impl std::error::Error for ParseError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BindError {
EmptySequence,
ConflictWithLeaf { existing_keys: String },
ConflictWithPrefix { existing_keys: String },
Parse(ParseError),
}
impl fmt::Display for BindError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptySequence => write!(f, "empty key sequence"),
Self::ConflictWithLeaf { existing_keys } => {
write!(
f,
"conflicts with existing leaf binding at \"{existing_keys}\""
)
}
Self::ConflictWithPrefix { existing_keys } => {
write!(f, "conflicts with existing prefix at \"{existing_keys}\"")
}
Self::Parse(e) => write!(f, "parse error: {e}"),
}
}
}
impl std::error::Error for BindError {}
impl From<ParseError> for BindError {
fn from(e: ParseError) -> Self {
Self::Parse(e)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModeError {
UnknownMode(String),
DuplicateMode(String),
}
impl fmt::Display for ModeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownMode(m) => write!(f, "unknown mode: \"{m}\""),
Self::DuplicateMode(m) => write!(f, "mode already exists: \"{m}\""),
}
}
}
impl std::error::Error for ModeError {}
+443
View File
@@ -0,0 +1,443 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::fmt;
use std::hash::{Hash, Hasher};
use crate::error::ParseError;
#[derive(Clone, Copy, Debug)]
pub struct Key {
pub code: KeyCode,
pub modifiers: KeyModifiers,
}
impl PartialEq for Key {
fn eq(&self, other: &Self) -> bool {
self.code == other.code && self.modifiers == other.modifiers
}
}
impl Eq for Key {}
impl Hash for Key {
fn hash<H: Hasher>(&self, state: &mut H) {
hash_key_code(&self.code, state);
self.modifiers.bits().hash(state);
}
}
fn hash_key_code<H: Hasher>(code: &KeyCode, state: &mut H) {
match code {
KeyCode::Backspace => (0u8).hash(state),
KeyCode::Enter => (1u8).hash(state),
KeyCode::Left => (2u8).hash(state),
KeyCode::Right => (3u8).hash(state),
KeyCode::Up => (4u8).hash(state),
KeyCode::Down => (5u8).hash(state),
KeyCode::Home => (6u8).hash(state),
KeyCode::End => (7u8).hash(state),
KeyCode::PageUp => (8u8).hash(state),
KeyCode::PageDown => (9u8).hash(state),
KeyCode::Tab => (10u8).hash(state),
KeyCode::BackTab => (11u8).hash(state),
KeyCode::Delete => (12u8).hash(state),
KeyCode::Insert => (13u8).hash(state),
KeyCode::F(n) => {
(14u8).hash(state);
n.hash(state);
}
KeyCode::Char(c) => {
(15u8).hash(state);
c.hash(state);
}
KeyCode::Null => (16u8).hash(state),
KeyCode::Esc => (17u8).hash(state),
KeyCode::CapsLock => (18u8).hash(state),
KeyCode::ScrollLock => (19u8).hash(state),
KeyCode::NumLock => (20u8).hash(state),
KeyCode::PrintScreen => (21u8).hash(state),
KeyCode::Pause => (22u8).hash(state),
KeyCode::Menu => (23u8).hash(state),
KeyCode::KeypadBegin => (24u8).hash(state),
KeyCode::Media(m) => {
(25u8).hash(state);
(*m as u8).hash(state);
}
KeyCode::Modifier(m) => {
(26u8).hash(state);
(*m as u8).hash(state);
}
}
}
impl From<KeyEvent> for Key {
fn from(e: KeyEvent) -> Self {
Key {
code: e.code,
modifiers: e.modifiers,
}
}
}
impl fmt::Display for Key {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.modifiers.contains(KeyModifiers::CONTROL) {
write!(f, "C-")?;
}
if self.modifiers.contains(KeyModifiers::SHIFT) {
write!(f, "S-")?;
}
if self.modifiers.contains(KeyModifiers::ALT) {
write!(f, "A-")?;
}
match self.code {
KeyCode::Char(' ') => write!(f, "SPC"),
KeyCode::Char(c) => write!(f, "{c}"),
KeyCode::Esc => write!(f, "Esc"),
KeyCode::Tab => write!(f, "Tab"),
KeyCode::Enter => write!(f, "Enter"),
KeyCode::Backspace => write!(f, "Backspace"),
KeyCode::F(n) => write!(f, "F{n}"),
KeyCode::Left => write!(f, "Left"),
KeyCode::Right => write!(f, "Right"),
KeyCode::Up => write!(f, "Up"),
KeyCode::Down => write!(f, "Down"),
KeyCode::Home => write!(f, "Home"),
KeyCode::End => write!(f, "End"),
KeyCode::PageUp => write!(f, "PageUp"),
KeyCode::PageDown => write!(f, "PageDown"),
KeyCode::Delete => write!(f, "Delete"),
KeyCode::Insert => write!(f, "Insert"),
_ => write!(f, "?"),
}
}
}
pub fn parse_key(input: &str) -> Result<Key, ParseError> {
if input.trim().is_empty() {
return Err(ParseError::EmptyInput);
}
if input.contains(' ') {
return Err(ParseError::UnknownKey(input.to_string()));
}
let trimmed = input;
let mut modifiers = KeyModifiers::NONE;
let mut remaining = trimmed;
let mut has_ctrl = false;
let mut has_shift = false;
let mut has_alt = false;
loop {
if remaining.starts_with("C-") {
if has_ctrl {
return Err(ParseError::DuplicateModifier);
}
has_ctrl = true;
modifiers |= KeyModifiers::CONTROL;
remaining = &remaining[2..];
} else if remaining.starts_with("S-") {
if has_shift {
return Err(ParseError::DuplicateModifier);
}
has_shift = true;
modifiers |= KeyModifiers::SHIFT;
remaining = &remaining[2..];
} else if remaining.starts_with("A-") {
if has_alt {
return Err(ParseError::DuplicateModifier);
}
has_alt = true;
modifiers |= KeyModifiers::ALT;
remaining = &remaining[2..];
} else {
break;
}
}
if remaining.is_empty() {
return Err(ParseError::DanglingModifier);
}
let code = match remaining {
"SPC" => KeyCode::Char(' '),
"Esc" => KeyCode::Esc,
"Tab" => KeyCode::Tab,
"Enter" => KeyCode::Enter,
"Backspace" => KeyCode::Backspace,
"Left" => KeyCode::Left,
"Right" => KeyCode::Right,
"Up" => KeyCode::Up,
"Down" => KeyCode::Down,
"Home" => KeyCode::Home,
"End" => KeyCode::End,
"PageUp" => KeyCode::PageUp,
"PageDown" => KeyCode::PageDown,
"Delete" => KeyCode::Delete,
"Insert" => KeyCode::Insert,
"-" => KeyCode::Char('-'),
s if s.starts_with('F') && s.len() > 1 => {
let num_str = &s[1..];
match num_str.parse::<u8>() {
Ok(n) if (1..=12).contains(&n) => KeyCode::F(n),
_ => return Err(ParseError::UnknownKey(input.to_string())),
}
}
s if s.len() == 1 => {
let c = s.chars().next().expect("non-empty string");
if !c.is_ascii() {
return Err(ParseError::UnknownKey(input.to_string()));
}
if has_shift && c.is_ascii_uppercase() {
return Err(ParseError::RedundantShift);
}
KeyCode::Char(c)
}
_ => return Err(ParseError::UnknownKey(input.to_string())),
};
Ok(Key { code, modifiers })
}
pub fn parse_sequence(input: &str) -> Result<Vec<Key>, ParseError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(ParseError::EmptyInput);
}
trimmed.split_whitespace().map(parse_key).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_input() {
assert_eq!(parse_key(""), Err(ParseError::EmptyInput));
assert_eq!(parse_key(" "), Err(ParseError::EmptyInput));
}
#[test]
fn test_space_in_key() {
assert!(matches!(parse_key(" j"), Err(ParseError::UnknownKey(_))));
}
#[test]
fn test_dangling_modifier() {
assert_eq!(parse_key("C-"), Err(ParseError::DanglingModifier));
}
#[test]
fn test_duplicate_modifier() {
assert_eq!(parse_key("C-C-d"), Err(ParseError::DuplicateModifier));
}
#[test]
fn test_lowercase_modifier() {
assert!(matches!(parse_key("c-d"), Err(ParseError::UnknownKey(_))));
}
#[test]
fn test_ctrl_d() {
let key = parse_key("C-d").unwrap();
assert_eq!(key.code, KeyCode::Char('d'));
assert_eq!(key.modifiers, KeyModifiers::CONTROL);
}
#[test]
fn test_ctrl_shift_d() {
let key = parse_key("C-S-d").unwrap();
assert_eq!(key.code, KeyCode::Char('d'));
assert_eq!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SHIFT);
}
#[test]
fn test_modifier_order_normalized() {
let key1 = parse_key("S-C-d").unwrap();
let key2 = parse_key("C-S-d").unwrap();
assert_eq!(key1, key2);
}
#[test]
fn test_uppercase_no_shift() {
let key = parse_key("G").unwrap();
assert_eq!(key.code, KeyCode::Char('G'));
assert_eq!(key.modifiers, KeyModifiers::NONE);
}
#[test]
fn test_shift_lowercase() {
let key = parse_key("S-g").unwrap();
assert_eq!(key.code, KeyCode::Char('g'));
assert_eq!(key.modifiers, KeyModifiers::SHIFT);
}
#[test]
fn test_redundant_shift() {
assert_eq!(parse_key("S-G"), Err(ParseError::RedundantShift));
}
#[test]
fn test_bare_modifier_letters() {
let key = parse_key("C").unwrap();
assert_eq!(key.code, KeyCode::Char('C'));
assert_eq!(key.modifiers, KeyModifiers::NONE);
let key = parse_key("S").unwrap();
assert_eq!(key.code, KeyCode::Char('S'));
assert_eq!(key.modifiers, KeyModifiers::NONE);
}
#[test]
fn test_special_keys() {
let key = parse_key("SPC").unwrap();
assert_eq!(key.code, KeyCode::Char(' '));
assert_eq!(key.modifiers, KeyModifiers::NONE);
assert!(matches!(parse_key("spc"), Err(ParseError::UnknownKey(_))));
let key = parse_key("Esc").unwrap();
assert_eq!(key.code, KeyCode::Esc);
assert_eq!(key.modifiers, KeyModifiers::NONE);
let key = parse_key("Tab").unwrap();
assert_eq!(key.code, KeyCode::Tab);
assert_eq!(key.modifiers, KeyModifiers::NONE);
let key = parse_key("Enter").unwrap();
assert_eq!(key.code, KeyCode::Enter);
assert_eq!(key.modifiers, KeyModifiers::NONE);
// Tab != 't'
assert_ne!(parse_key("Tab").unwrap(), parse_key("t").unwrap());
}
#[test]
fn test_function_keys() {
let key = parse_key("F1").unwrap();
assert_eq!(key.code, KeyCode::F(1));
assert_eq!(key.modifiers, KeyModifiers::NONE);
let key = parse_key("F12").unwrap();
assert_eq!(key.code, KeyCode::F(12));
assert_eq!(key.modifiers, KeyModifiers::NONE);
assert!(matches!(parse_key("F0"), Err(ParseError::UnknownKey(_))));
assert!(matches!(parse_key("F13"), Err(ParseError::UnknownKey(_))));
let key = parse_key("F").unwrap();
assert_eq!(key.code, KeyCode::Char('F'));
assert_eq!(key.modifiers, KeyModifiers::NONE);
let key = parse_key("C-F1").unwrap();
assert_eq!(key.code, KeyCode::F(1));
assert_eq!(key.modifiers, KeyModifiers::CONTROL);
}
#[test]
fn test_symbols() {
let key = parse_key("-").unwrap();
assert_eq!(key.code, KeyCode::Char('-'));
assert_eq!(key.modifiers, KeyModifiers::NONE);
let key = parse_key("C--").unwrap();
assert_eq!(key.code, KeyCode::Char('-'));
assert_eq!(key.modifiers, KeyModifiers::CONTROL);
let key = parse_key("[").unwrap();
assert_eq!(key.code, KeyCode::Char('['));
assert_eq!(key.modifiers, KeyModifiers::NONE);
let key = parse_key("?").unwrap();
assert_eq!(key.code, KeyCode::Char('?'));
assert_eq!(key.modifiers, KeyModifiers::NONE);
}
#[test]
fn test_digits() {
for c in '0'..='9' {
let key = parse_key(&c.to_string()).unwrap();
assert_eq!(key.code, KeyCode::Char(c));
assert_eq!(key.modifiers, KeyModifiers::NONE);
}
}
#[test]
fn test_invalid_non_ascii() {
assert!(matches!(parse_key("é"), Err(ParseError::UnknownKey(_))));
}
#[test]
fn test_space_is_sequence() {
assert!(matches!(parse_key("g g"), Err(ParseError::UnknownKey(_))));
}
#[test]
fn test_display_roundtrip() {
let test_cases = [
"j", "G", "C-d", "C-S-d", "SPC", "Esc", "Tab", "Enter", "F1", "-", "C--", "[",
];
for input in test_cases {
let key = parse_key(input).unwrap();
let displayed = key.to_string();
let reparsed = parse_key(&displayed).unwrap();
assert_eq!(key, reparsed, "roundtrip failed for {input}");
}
}
#[test]
fn test_display_specific() {
let key = Key {
code: KeyCode::Char(' '),
modifiers: KeyModifiers::NONE,
};
assert_eq!(key.to_string(), "SPC");
let key = Key {
code: KeyCode::Char('d'),
modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT,
};
assert_eq!(key.to_string(), "C-S-d");
}
#[test]
fn test_sequence_parsing() {
let seq = parse_sequence("g g").unwrap();
assert_eq!(seq.len(), 2);
assert_eq!(seq[0].code, KeyCode::Char('g'));
assert_eq!(seq[1].code, KeyCode::Char('g'));
let seq = parse_sequence("SPC b l").unwrap();
assert_eq!(seq.len(), 3);
assert_eq!(seq[0].code, KeyCode::Char(' '));
assert_eq!(seq[1].code, KeyCode::Char('b'));
assert_eq!(seq[2].code, KeyCode::Char('l'));
assert_eq!(parse_sequence(""), Err(ParseError::EmptyInput));
assert_eq!(parse_sequence(" "), Err(ParseError::EmptyInput));
}
#[test]
fn test_from_key_event() {
let event = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
let key = Key::from(event);
assert_eq!(key.code, KeyCode::Char('j'));
assert_eq!(key.modifiers, KeyModifiers::NONE);
}
#[test]
fn test_key_hash() {
use std::collections::HashMap;
let mut map = HashMap::new();
let key1 = parse_key("C-d").unwrap();
let key2 = parse_key("C-d").unwrap();
map.insert(key1, "action");
assert_eq!(map.get(&key2), Some(&"action"));
}
}
+13
View File
@@ -0,0 +1,13 @@
pub mod count;
pub mod dispatch;
pub mod error;
pub mod key;
pub mod timeout;
pub mod trie;
pub mod which_key;
pub use dispatch::{DispatchResult, Dispatcher};
pub use error::{BindError, ModeError, ParseError};
pub use key::Key;
pub use trie::{KeyTrie, KeyTrieNode, LeafBinding, SearchResult};
pub use which_key::WhichKeyEntry;
+101
View File
@@ -0,0 +1,101 @@
use std::time::{Duration, Instant};
pub struct TimeoutTracker {
timeout: Duration,
started_at: Option<Instant>,
}
impl TimeoutTracker {
pub fn new(timeout: Duration) -> Self {
Self {
timeout,
started_at: None,
}
}
pub fn start(&mut self) {
self.started_at = Some(Instant::now());
}
pub fn check(&mut self) -> bool {
if let Some(started) = self.started_at {
if started.elapsed() >= self.timeout {
self.started_at = None;
return true;
}
}
false
}
pub fn elapsed(&self) -> Duration {
self.started_at
.map(|s| s.elapsed())
.unwrap_or(Duration::ZERO)
}
pub fn reset(&mut self) {
self.started_at = None;
}
pub fn is_active(&self) -> bool {
self.started_at.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn test_check_nothing_started() {
let mut tracker = TimeoutTracker::new(Duration::from_millis(100));
assert!(!tracker.check());
}
#[test]
fn test_check_expired() {
let mut tracker = TimeoutTracker::new(Duration::from_millis(100));
tracker.start();
thread::sleep(Duration::from_millis(150));
assert!(tracker.check());
}
#[test]
fn test_check_not_expired() {
let mut tracker = TimeoutTracker::new(Duration::from_millis(100));
tracker.start();
assert!(!tracker.check());
}
#[test]
fn test_reset() {
let mut tracker = TimeoutTracker::new(Duration::from_millis(100));
tracker.start();
tracker.reset();
assert!(!tracker.check());
}
#[test]
fn test_elapsed_nothing_started() {
let tracker = TimeoutTracker::new(Duration::from_millis(100));
assert_eq!(tracker.elapsed(), Duration::ZERO);
}
#[test]
fn test_zero_timeout() {
let mut tracker = TimeoutTracker::new(Duration::ZERO);
tracker.start();
assert!(tracker.check());
}
#[test]
fn test_is_active() {
let mut tracker = TimeoutTracker::new(Duration::from_millis(100));
assert!(!tracker.is_active());
tracker.start();
assert!(tracker.is_active());
tracker.reset();
assert!(!tracker.is_active());
}
}
+414
View File
@@ -0,0 +1,414 @@
use crate::error::BindError;
use crate::key::{parse_sequence, Key};
use indexmap::IndexMap;
pub enum KeyTrie<A> {
Leaf(LeafBinding<A>),
Node(KeyTrieNode<A>),
}
pub struct LeafBinding<A> {
pub action: A,
pub description: Option<String>,
}
pub struct KeyTrieNode<A> {
pub name: String,
pub map: IndexMap<Key, KeyTrie<A>>,
}
pub enum SearchResult<'a, A> {
Found(&'a LeafBinding<A>),
Prefix(&'a KeyTrieNode<A>),
NotFound,
}
impl<A> KeyTrie<A> {
pub fn new(name: &str) -> Self {
KeyTrie::Node(KeyTrieNode {
name: name.to_string(),
map: IndexMap::new(),
})
}
pub fn bind(&mut self, keys: &str, action: A) -> Result<(), BindError> {
self.bind_internal(keys, action, None)
}
pub fn bind_desc(&mut self, keys: &str, action: A, desc: &str) -> Result<(), BindError> {
self.bind_internal(keys, action, Some(desc.to_string()))
}
fn bind_internal(
&mut self,
keys: &str,
action: A,
description: Option<String>,
) -> Result<(), BindError> {
let sequence = parse_sequence(keys)?;
if sequence.is_empty() {
return Err(BindError::EmptySequence);
}
let node = match self {
KeyTrie::Node(n) => n,
KeyTrie::Leaf(_) => {
return Err(BindError::ConflictWithLeaf {
existing_keys: String::new(),
})
}
};
node.bind_sequence(&sequence, action, description, String::new())
}
pub fn group<F>(&mut self, key: &str, name: &str, f: F) -> Result<(), BindError>
where
F: FnOnce(&mut KeyTrieNode<A>) -> Result<(), BindError>,
{
let parsed_key = crate::key::parse_key(key)?;
let node = match self {
KeyTrie::Node(n) => n,
KeyTrie::Leaf(_) => {
return Err(BindError::ConflictWithLeaf {
existing_keys: String::new(),
})
}
};
let entry = node.map.entry(parsed_key);
let child_node = match entry {
indexmap::map::Entry::Occupied(o) => match o.into_mut() {
KeyTrie::Node(n) => n,
KeyTrie::Leaf(_) => {
return Err(BindError::ConflictWithLeaf {
existing_keys: key.to_string(),
})
}
},
indexmap::map::Entry::Vacant(v) => {
let new_node = KeyTrie::Node(KeyTrieNode {
name: name.to_string(),
map: IndexMap::new(),
});
match v.insert(new_node) {
KeyTrie::Node(n) => n,
KeyTrie::Leaf(_) => unreachable!(),
}
}
};
f(child_node)
}
pub fn search(&self, keys: &[Key]) -> SearchResult<'_, A> {
if keys.is_empty() {
return SearchResult::NotFound;
}
let node = match self {
KeyTrie::Node(n) => n,
KeyTrie::Leaf(l) => return SearchResult::Found(l),
};
node.search(keys)
}
}
impl<A> KeyTrieNode<A> {
pub fn bind(&mut self, keys: &str, action: A) -> Result<(), BindError> {
let sequence = parse_sequence(keys)?;
if sequence.is_empty() {
return Err(BindError::EmptySequence);
}
self.bind_sequence(&sequence, action, None, String::new())
}
pub fn bind_desc(&mut self, keys: &str, action: A, desc: &str) -> Result<(), BindError> {
let sequence = parse_sequence(keys)?;
if sequence.is_empty() {
return Err(BindError::EmptySequence);
}
self.bind_sequence(&sequence, action, Some(desc.to_string()), String::new())
}
fn bind_sequence(
&mut self,
keys: &[Key],
action: A,
description: Option<String>,
path: String,
) -> Result<(), BindError> {
let (first, rest) = keys.split_first().expect("non-empty keys");
let current_path = if path.is_empty() {
first.to_string()
} else {
format!("{} {}", path, first)
};
if rest.is_empty() {
match self.map.get(first) {
Some(KeyTrie::Node(_)) => {
return Err(BindError::ConflictWithPrefix {
existing_keys: current_path,
});
}
Some(KeyTrie::Leaf(_)) | None => {
self.map.insert(
*first,
KeyTrie::Leaf(LeafBinding {
action,
description,
}),
);
return Ok(());
}
}
}
let entry = self.map.entry(*first);
match entry {
indexmap::map::Entry::Occupied(mut o) => match o.get_mut() {
KeyTrie::Leaf(_) => {
return Err(BindError::ConflictWithLeaf {
existing_keys: current_path,
});
}
KeyTrie::Node(n) => {
n.bind_sequence(rest, action, description, current_path)?;
}
},
indexmap::map::Entry::Vacant(v) => {
let mut new_node = KeyTrieNode {
name: String::new(),
map: IndexMap::new(),
};
new_node.bind_sequence(rest, action, description, current_path)?;
v.insert(KeyTrie::Node(new_node));
}
}
Ok(())
}
pub fn search(&self, keys: &[Key]) -> SearchResult<'_, A> {
if keys.is_empty() {
return SearchResult::NotFound;
}
let (first, rest) = keys.split_first().expect("non-empty keys");
match self.map.get(first) {
None => SearchResult::NotFound,
Some(KeyTrie::Leaf(l)) if rest.is_empty() => SearchResult::Found(l),
Some(KeyTrie::Leaf(_)) => SearchResult::NotFound,
Some(KeyTrie::Node(n)) if rest.is_empty() => SearchResult::Prefix(n),
Some(KeyTrie::Node(n)) => n.search(rest),
}
}
pub fn group<F>(&mut self, key: &str, name: &str, f: F) -> Result<(), BindError>
where
F: FnOnce(&mut KeyTrieNode<A>) -> Result<(), BindError>,
{
let parsed_key = crate::key::parse_key(key)?;
let entry = self.map.entry(parsed_key);
let child_node = match entry {
indexmap::map::Entry::Occupied(o) => match o.into_mut() {
KeyTrie::Node(n) => n,
KeyTrie::Leaf(_) => {
return Err(BindError::ConflictWithLeaf {
existing_keys: key.to_string(),
})
}
},
indexmap::map::Entry::Vacant(v) => {
let new_node = KeyTrie::Node(KeyTrieNode {
name: name.to_string(),
map: IndexMap::new(),
});
match v.insert(new_node) {
KeyTrie::Node(n) => n,
KeyTrie::Leaf(_) => unreachable!(),
}
}
};
f(child_node)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::key::parse_key;
#[test]
fn test_conflict_leaf_then_prefix() {
let mut trie: KeyTrie<&str> = KeyTrie::new("root");
trie.bind("g", "action").unwrap();
let result = trie.bind("g g", "action2");
assert!(matches!(result, Err(BindError::ConflictWithLeaf { .. })));
}
#[test]
fn test_conflict_prefix_then_leaf() {
let mut trie: KeyTrie<&str> = KeyTrie::new("root");
trie.bind("g g", "action").unwrap();
let result = trie.bind("g", "action2");
assert!(matches!(result, Err(BindError::ConflictWithPrefix { .. })));
}
#[test]
fn test_siblings_ok() {
let mut trie: KeyTrie<&str> = KeyTrie::new("root");
trie.bind("g g", "action1").unwrap();
trie.bind("g h", "action2").unwrap();
}
#[test]
fn test_overwrite() {
let mut trie: KeyTrie<&str> = KeyTrie::new("root");
trie.bind("g", "first").unwrap();
trie.bind("g", "second").unwrap();
let key = parse_key("g").unwrap();
if let SearchResult::Found(leaf) = trie.search(&[key]) {
assert_eq!(leaf.action, "second");
} else {
panic!("expected Found");
}
}
#[test]
fn test_empty_bind() {
let mut trie: KeyTrie<&str> = KeyTrie::new("root");
let result = trie.bind("", "action");
assert!(matches!(result, Err(BindError::Parse(_))));
}
#[test]
fn test_search_empty_keys() {
let trie: KeyTrie<&str> = KeyTrie::new("root");
assert!(matches!(trie.search(&[]), SearchResult::NotFound));
}
#[test]
fn test_search_empty_trie() {
let trie: KeyTrie<&str> = KeyTrie::new("root");
let key = parse_key("g").unwrap();
assert!(matches!(trie.search(&[key]), SearchResult::NotFound));
}
#[test]
fn test_search_prefix() {
let mut trie: KeyTrie<&str> = KeyTrie::new("root");
trie.bind("g g", "action").unwrap();
let g = parse_key("g").unwrap();
assert!(matches!(trie.search(&[g]), SearchResult::Prefix(_)));
}
#[test]
fn test_search_found() {
let mut trie: KeyTrie<&str> = KeyTrie::new("root");
trie.bind("g g", "action").unwrap();
let g = parse_key("g").unwrap();
if let SearchResult::Found(leaf) = trie.search(&[g, g]) {
assert_eq!(leaf.action, "action");
} else {
panic!("expected Found");
}
}
#[test]
fn test_search_not_found_wrong_key() {
let mut trie: KeyTrie<&str> = KeyTrie::new("root");
trie.bind("g g", "action").unwrap();
let g = parse_key("g").unwrap();
let h = parse_key("h").unwrap();
assert!(matches!(trie.search(&[g, h]), SearchResult::NotFound));
}
#[test]
fn test_search_beyond_leaf() {
let mut trie: KeyTrie<&str> = KeyTrie::new("root");
trie.bind("g", "action").unwrap();
let g = parse_key("g").unwrap();
let h = parse_key("h").unwrap();
assert!(matches!(trie.search(&[g, h]), SearchResult::NotFound));
}
#[test]
fn test_group_then_bind_at_group_key() {
let mut trie: KeyTrie<&str> = KeyTrie::new("root");
trie.group("g", "goto", |_| Ok(())).unwrap();
let result = trie.bind("g", "action");
assert!(matches!(result, Err(BindError::ConflictWithPrefix { .. })));
}
#[test]
fn test_empty_group() {
let mut trie: KeyTrie<&str> = KeyTrie::new("root");
trie.group("g", "goto", |_| Ok(())).unwrap();
let g = parse_key("g").unwrap();
if let SearchResult::Prefix(node) = trie.search(&[g]) {
assert!(node.map.is_empty());
} else {
panic!("expected Prefix");
}
}
#[test]
fn test_deep_nesting() {
let mut trie: KeyTrie<&str> = KeyTrie::new("root");
trie.bind("a b c d e f g h", "deep").unwrap();
let keys: Vec<Key> = "a b c d e f g h"
.split_whitespace()
.map(|s| parse_key(s).unwrap())
.collect();
assert!(matches!(trie.search(&keys), SearchResult::Found(_)));
}
#[test]
fn test_wide_single_level() {
let mut trie: KeyTrie<&str> = KeyTrie::new("root");
for c in 'a'..='z' {
trie.bind(&c.to_string(), "action").unwrap();
}
for c in 'a'..='z' {
let key = parse_key(&c.to_string()).unwrap();
assert!(matches!(trie.search(&[key]), SearchResult::Found(_)));
}
}
#[test]
fn test_bind_with_description() {
let mut trie: KeyTrie<&str> = KeyTrie::new("root");
trie.bind_desc("j", "down", "Move down").unwrap();
let j = parse_key("j").unwrap();
if let SearchResult::Found(leaf) = trie.search(&[j]) {
assert_eq!(leaf.description, Some("Move down".to_string()));
} else {
panic!("expected Found");
}
}
}
+111
View File
@@ -0,0 +1,111 @@
use crate::trie::{KeyTrie, KeyTrieNode};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WhichKeyEntry {
pub key: String,
pub description: String,
pub is_group: bool,
}
impl<A> KeyTrieNode<A> {
pub fn which_key_entries(&self) -> Vec<WhichKeyEntry> {
let mut groups = Vec::new();
let mut leaves = Vec::new();
for (key, trie) in &self.map {
match trie {
KeyTrie::Node(node) => {
groups.push(WhichKeyEntry {
key: key.to_string(),
description: node.name.clone(),
is_group: true,
});
}
KeyTrie::Leaf(leaf) => {
leaves.push(WhichKeyEntry {
key: key.to_string(),
description: leaf.description.clone().unwrap_or_default(),
is_group: false,
});
}
}
}
groups.sort_by(|a, b| a.key.cmp(&b.key));
leaves.sort_by(|a, b| a.key.cmp(&b.key));
groups.extend(leaves);
groups
}
}
#[cfg(test)]
mod tests {
use crate::trie::KeyTrie;
#[test]
fn test_empty_node() {
let trie: KeyTrie<&str> = KeyTrie::new("root");
if let KeyTrie::Node(node) = trie {
let entries = node.which_key_entries();
assert!(entries.is_empty());
}
}
#[test]
fn test_leaves_only_sorted() {
let mut trie: KeyTrie<&str> = KeyTrie::new("root");
trie.bind_desc("c", "action_c", "C action").unwrap();
trie.bind_desc("a", "action_a", "A action").unwrap();
trie.bind_desc("b", "action_b", "B action").unwrap();
if let KeyTrie::Node(node) = trie {
let entries = node.which_key_entries();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].key, "a");
assert_eq!(entries[1].key, "b");
assert_eq!(entries[2].key, "c");
}
}
#[test]
fn test_groups_before_leaves() {
let mut trie: KeyTrie<&str> = KeyTrie::new("root");
trie.bind_desc("z", "action", "Z action").unwrap();
trie.group("g", "goto", |node| {
node.bind("g", "goto_top")?;
Ok(())
})
.unwrap();
trie.bind_desc("a", "action", "A action").unwrap();
if let KeyTrie::Node(node) = trie {
let entries = node.which_key_entries();
assert_eq!(entries.len(), 3);
assert!(entries[0].is_group);
assert_eq!(entries[0].key, "g");
assert!(!entries[1].is_group);
assert_eq!(entries[1].key, "a");
assert!(!entries[2].is_group);
assert_eq!(entries[2].key, "z");
}
}
#[test]
fn test_descriptions_populated() {
let mut trie: KeyTrie<&str> = KeyTrie::new("root");
trie.bind_desc("j", "down", "Move down").unwrap();
trie.group("g", "goto", |_| Ok(())).unwrap();
if let KeyTrie::Node(node) = trie {
let entries = node.which_key_entries();
let group = entries.iter().find(|e| e.key == "g").unwrap();
assert_eq!(group.description, "goto");
assert!(group.is_group);
let leaf = entries.iter().find(|e| e.key == "j").unwrap();
assert_eq!(leaf.description, "Move down");
assert!(!leaf.is_group);
}
}
}
@@ -0,0 +1,403 @@
use std::thread;
use std::time::Duration;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use evil_keys::{DispatchResult, Dispatcher, KeyTrie};
fn press(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn press_char(c: char) -> KeyEvent {
press(KeyCode::Char(c))
}
fn press_spc() -> KeyEvent {
press_char(' ')
}
fn press_esc() -> KeyEvent {
press(KeyCode::Esc)
}
fn vim_dispatcher() -> Dispatcher<&'static str> {
let mut trie = KeyTrie::new("normal");
trie.bind("j", "move_down").unwrap();
trie.bind("k", "move_up").unwrap();
trie.bind("G", "goto_last").unwrap();
trie.bind("Esc", "escape").unwrap();
trie.bind("0", "goto_start").unwrap();
trie.group("g", "+goto", |g| {
g.bind("g", "goto_first")?;
g.bind("t", "next_tab")?;
g.bind("T", "prev_tab")?;
Ok(())
})
.unwrap();
trie.group("SPC", "+leader", |g| {
g.group("b", "+buffer", |b| {
b.bind_desc("l", "goto_library", "library")?;
b.bind_desc("w", "goto_wanted", "wanted")?;
Ok(())
})?;
g.bind_desc("h", "show_help", "help")?;
g.bind_desc("q", "quit", "quit")?;
g.bind_desc("n", "notifications", "notifications")?;
Ok(())
})
.unwrap();
let insert = KeyTrie::new("insert");
let mut d = Dispatcher::new();
d.add_mode("normal", trie).unwrap();
d.add_mode("insert", insert).unwrap();
d.set_active("normal").unwrap();
d
}
#[test]
fn vim_workflow_5gg() {
let mut d = vim_dispatcher();
assert_eq!(
d.dispatch(press_char('5')),
DispatchResult::CountAccumulated
);
assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending);
assert_eq!(
d.dispatch(press_char('g')),
DispatchResult::Matched {
action: "goto_first",
count: 5
}
);
}
#[test]
fn leader_spc_b_l() {
let mut d = vim_dispatcher();
assert_eq!(d.dispatch(press_spc()), DispatchResult::Pending);
assert_eq!(d.dispatch(press_char('b')), DispatchResult::Pending);
assert_eq!(
d.dispatch(press_char('l')),
DispatchResult::Matched {
action: "goto_library",
count: 1
}
);
}
#[test]
fn escape_progression() {
let mut d = vim_dispatcher();
assert_eq!(
d.dispatch(press_char('5')),
DispatchResult::CountAccumulated
);
assert_eq!(d.dispatch(press_esc()), DispatchResult::Cancelled);
assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending);
assert_eq!(d.dispatch(press_esc()), DispatchResult::Cancelled);
assert_eq!(
d.dispatch(press_char('3')),
DispatchResult::CountAccumulated
);
assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending);
assert_eq!(d.dispatch(press_esc()), DispatchResult::Cancelled);
assert_eq!(
d.dispatch(press_char('j')),
DispatchResult::Matched {
action: "move_down",
count: 1
}
);
}
#[test]
fn timeout_resets_sequence() {
let mut d = vim_dispatcher();
d.set_timeout(Duration::from_millis(50));
assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending);
thread::sleep(Duration::from_millis(80));
assert!(d.check_timeout());
assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending);
assert_eq!(
d.dispatch(press_char('g')),
DispatchResult::Matched {
action: "goto_first",
count: 1
}
);
}
#[test]
fn timeout_clears_count_too() {
let mut d = vim_dispatcher();
d.set_timeout(Duration::from_millis(50));
assert_eq!(
d.dispatch(press_char('5')),
DispatchResult::CountAccumulated
);
assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending);
thread::sleep(Duration::from_millis(80));
assert!(d.check_timeout());
assert_eq!(
d.dispatch(press_char('j')),
DispatchResult::Matched {
action: "move_down",
count: 1
}
);
}
#[test]
fn mode_switch_mid_sequence() {
let mut d = vim_dispatcher();
assert_eq!(
d.dispatch(press_char('3')),
DispatchResult::CountAccumulated
);
assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending);
d.set_active("insert").unwrap();
d.set_active("normal").unwrap();
assert_eq!(
d.dispatch(press_char('j')),
DispatchResult::Matched {
action: "move_down",
count: 1
}
);
}
#[test]
fn which_key_after_leader() {
let mut d = vim_dispatcher();
assert_eq!(d.dispatch(press_spc()), DispatchResult::Pending);
let entries = d.which_key_entries().unwrap();
assert!(entries.iter().any(|e| e.key == "b" && e.is_group));
assert!(entries
.iter()
.any(|e| e.key == "h" && e.description == "help"));
assert!(entries
.iter()
.any(|e| e.key == "q" && e.description == "quit"));
assert!(entries
.iter()
.any(|e| e.key == "n" && e.description == "notifications"));
assert_eq!(d.dispatch(press_char('b')), DispatchResult::Pending);
let entries = d.which_key_entries().unwrap();
assert!(entries
.iter()
.any(|e| e.key == "l" && e.description == "library"));
assert!(entries
.iter()
.any(|e| e.key == "w" && e.description == "wanted"));
assert_eq!(
d.dispatch(press_char('l')),
DispatchResult::Matched {
action: "goto_library",
count: 1
}
);
assert!(d.which_key_entries().is_none());
}
#[test]
fn stress_rapid_fire() {
let mut d = vim_dispatcher();
for _ in 0..1000 {
assert_eq!(
d.dispatch(press_char('j')),
DispatchResult::Matched {
action: "move_down",
count: 1
}
);
}
assert!(d.pending_keys().is_empty());
}
#[test]
fn stress_alternating_sequences() {
let mut d = vim_dispatcher();
for _ in 0..100 {
assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending);
assert_eq!(
d.dispatch(press_char('g')),
DispatchResult::Matched {
action: "goto_first",
count: 1
}
);
assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending);
assert_eq!(
d.dispatch(press_char('t')),
DispatchResult::Matched {
action: "next_tab",
count: 1
}
);
}
}
#[test]
fn count_123_j() {
let mut d = vim_dispatcher();
assert_eq!(
d.dispatch(press_char('1')),
DispatchResult::CountAccumulated
);
assert_eq!(
d.dispatch(press_char('2')),
DispatchResult::CountAccumulated
);
assert_eq!(
d.dispatch(press_char('3')),
DispatchResult::CountAccumulated
);
assert_eq!(
d.dispatch(press_char('j')),
DispatchResult::Matched {
action: "move_down",
count: 123
}
);
}
#[test]
fn zero_as_binding_not_count() {
let mut d = vim_dispatcher();
assert_eq!(
d.dispatch(press_char('0')),
DispatchResult::Matched {
action: "goto_start",
count: 1
}
);
}
#[test]
fn zero_after_digit_is_count() {
let mut d = vim_dispatcher();
assert_eq!(
d.dispatch(press_char('1')),
DispatchResult::CountAccumulated
);
assert_eq!(
d.dispatch(press_char('0')),
DispatchResult::CountAccumulated
);
assert_eq!(
d.dispatch(press_char('j')),
DispatchResult::Matched {
action: "move_down",
count: 10
}
);
}
#[test]
fn count_overflow_saturates() {
let mut d = vim_dispatcher();
for _ in 0..20 {
assert_eq!(
d.dispatch(press_char('9')),
DispatchResult::CountAccumulated
);
}
let result = d.dispatch(press_char('j'));
assert!(matches!(
result,
DispatchResult::Matched {
action: "move_down",
count
} if count == usize::MAX
));
}
#[test]
fn pending_display_shows_keys() {
let mut d = vim_dispatcher();
assert_eq!(d.pending_display(), "");
d.dispatch(press_spc());
assert_eq!(d.pending_display(), "SPC");
d.dispatch(press_char('b'));
assert_eq!(d.pending_display(), "SPC b");
}
#[test]
fn escape_with_nothing_pending_matches_binding() {
let mut d = vim_dispatcher();
assert_eq!(
d.dispatch(press_esc()),
DispatchResult::Matched {
action: "escape",
count: 1
}
);
}
#[test]
fn invalid_mode() {
let mut d = vim_dispatcher();
assert!(d.set_active("nonexistent").is_err());
}
#[test]
fn dispatch_with_no_modes() {
let mut d: Dispatcher<&str> = Dispatcher::new();
assert_eq!(d.dispatch(press_char('j')), DispatchResult::NotFound);
}
#[test]
fn key_release_ignored() {
let mut d = vim_dispatcher();
let release = KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: KeyEventState::NONE,
};
assert_eq!(d.dispatch(release), DispatchResult::Ignored);
}
#[test]
fn key_repeat_ignored() {
let mut d = vim_dispatcher();
let repeat = KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Repeat,
state: KeyEventState::NONE,
};
assert_eq!(d.dispatch(repeat), DispatchResult::Ignored);
}
#[test]
fn wrong_key_mid_sequence_resets() {
let mut d = vim_dispatcher();
assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending);
assert_eq!(d.dispatch(press_char('h')), DispatchResult::NotFound);
assert_eq!(d.dispatch(press_char('g')), DispatchResult::Pending);
}
@@ -0,0 +1,144 @@
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use evil_keys::key::{parse_key, parse_sequence};
use evil_keys::{DispatchResult, Dispatcher, Key, KeyTrie};
use proptest::prelude::*;
fn valid_key_string() -> impl Strategy<Value = String> {
prop_oneof![
prop::char::range('a', 'z').prop_map(|c| c.to_string()),
prop::char::range('A', 'Z').prop_map(|c| c.to_string()),
prop::char::range('0', '9').prop_map(|c| c.to_string()),
Just("SPC".to_string()),
Just("Esc".to_string()),
Just("Tab".to_string()),
Just("Enter".to_string()),
Just("-".to_string()),
Just("[".to_string()),
Just("]".to_string()),
Just("/".to_string()),
Just("?".to_string()),
(1u8..=12).prop_map(|n| format!("F{n}")),
prop::char::range('a', 'z').prop_map(|c| format!("C-{c}")),
]
}
fn test_dispatcher() -> Dispatcher<&'static str> {
let mut trie = KeyTrie::new("normal");
trie.bind("j", "down").unwrap();
trie.bind("k", "up").unwrap();
trie.bind("Esc", "escape").unwrap();
trie.group("g", "+goto", |g| {
g.bind("g", "goto_first")?;
g.bind("t", "next_tab")?;
Ok(())
})
.unwrap();
let mut d = Dispatcher::new();
d.add_mode("normal", trie).unwrap();
d.set_active("normal").unwrap();
d
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
}
}
fn press_char(c: char) -> KeyEvent {
press(KeyCode::Char(c))
}
proptest! {
#[test]
fn key_parse_display_roundtrip(s in valid_key_string()) {
let key = parse_key(&s).unwrap();
let displayed = key.to_string();
let reparsed = parse_key(&displayed).unwrap();
prop_assert_eq!(key, reparsed);
}
#[test]
fn key_parse_never_panics(s in "\\PC{0,20}") {
let _ = parse_key(&s);
}
#[test]
fn key_display_never_panics(c in any::<char>().prop_filter("ascii", |c| c.is_ascii() && !c.is_control())) {
let key = Key { code: KeyCode::Char(c), modifiers: KeyModifiers::NONE };
let _ = key.to_string();
}
#[test]
fn non_press_always_ignored(code_char in prop::char::range('a', 'z')) {
let mut d = test_dispatcher();
let release = KeyEvent {
code: KeyCode::Char(code_char),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Release,
state: KeyEventState::NONE,
};
prop_assert_eq!(d.dispatch(release), DispatchResult::Ignored);
let repeat = KeyEvent {
code: KeyCode::Char(code_char),
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Repeat,
state: KeyEventState::NONE,
};
prop_assert_eq!(d.dispatch(repeat), DispatchResult::Ignored);
}
#[test]
fn count_take_always_at_least_1(digits in prop::collection::vec(prop::char::range('1', '9'), 0..10)) {
let mut count = evil_keys::count::CountState::new();
for d in digits {
count.push_digit(d);
}
prop_assert!(count.take() >= 1);
}
#[test]
fn escape_always_clears_pending(keys_before in prop::collection::vec(prop::char::range('a', 'z'), 0..5)) {
let mut d = test_dispatcher();
for c in keys_before {
let _ = d.dispatch(press_char(c));
}
let _ = d.dispatch(press(KeyCode::Esc));
prop_assert!(d.pending_keys().is_empty());
}
#[test]
fn trie_bound_keys_always_found(keys in prop::collection::vec(valid_key_string(), 1..10)) {
let mut trie: KeyTrie<usize> = KeyTrie::new("test");
let mut successful = vec![];
for (i, key) in keys.iter().enumerate() {
if trie.bind(key, i).is_ok() {
successful.push(key.clone());
}
}
for key in &successful {
let seq = parse_sequence(key).unwrap();
prop_assert!(matches!(
trie.search(&seq),
evil_keys::SearchResult::Found(_)
));
}
}
#[test]
fn search_empty_trie_never_panics(keys in prop::collection::vec(valid_key_string(), 0..5)) {
let trie: KeyTrie<()> = KeyTrie::new("test");
let parsed: Vec<Key> = keys
.iter()
.filter_map(|k| parse_key(k).ok())
.collect();
let _ = trie.search(&parsed);
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "which-key"
version = "0.1.0"
edition = "2021"
[dependencies]
ratatui = "0.29"
unicode-width = "0.2"
[dev-dependencies]
proptest = "1.4"
insta = "1.40.0"
+96
View File
@@ -0,0 +1,96 @@
//! Key hint representation for the which-key widget.
/// A single key hint entry showing a key and its description.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeyHint {
/// The key or key combination (e.g., "a", "gg", "<C-d>")
pub key: String,
/// Description of what the key does
pub description: String,
/// Whether this hint represents a group (prefix for more keys)
pub is_group: bool,
}
impl KeyHint {
/// Create a new key hint.
pub fn new(key: impl Into<String>, desc: impl Into<String>) -> Self {
Self {
key: key.into(),
description: desc.into(),
is_group: false,
}
}
/// Mark this hint as a group (prefix key).
pub fn group(mut self) -> Self {
self.is_group = true;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_construction() {
let hint = KeyHint::new("a", "action");
assert_eq!(hint.key, "a");
assert_eq!(hint.description, "action");
assert!(!hint.is_group);
}
#[test]
fn test_string_types() {
let hint = KeyHint::new(String::from("b"), String::from("buffer"));
assert_eq!(hint.key, "b");
assert_eq!(hint.description, "buffer");
}
#[test]
fn test_group_builder() {
let hint = KeyHint::new("g", "+goto").group();
assert!(hint.is_group);
assert_eq!(hint.key, "g");
assert_eq!(hint.description, "+goto");
}
#[test]
fn test_group_preserves_other_fields() {
let hint = KeyHint::new("SPC", "leader").group();
assert_eq!(hint.key, "SPC");
assert_eq!(hint.description, "leader");
assert!(hint.is_group);
}
#[test]
fn test_clone() {
let hint1 = KeyHint::new("x", "delete");
let hint2 = hint1.clone();
assert_eq!(hint1, hint2);
}
#[test]
fn test_debug() {
let hint = KeyHint::new("q", "quit");
let debug_str = format!("{:?}", hint);
assert!(debug_str.contains("KeyHint"));
assert!(debug_str.contains("quit"));
}
#[test]
fn test_equality() {
let hint1 = KeyHint::new("a", "action");
let hint2 = KeyHint::new("a", "action");
let hint3 = KeyHint::new("b", "action");
assert_eq!(hint1, hint2);
assert_ne!(hint1, hint3);
}
#[test]
fn test_group_affects_equality() {
let hint1 = KeyHint::new("a", "action");
let hint2 = KeyHint::new("a", "action").group();
assert_ne!(hint1, hint2);
}
}
+493
View File
@@ -0,0 +1,493 @@
use std::borrow::Cow;
use ratatui::layout::Alignment;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
#[derive(Clone, Copy, Debug)]
pub(crate) enum DimConstraint {
#[allow(dead_code)]
Fixed(f64),
Range { min: Option<f64>, max: Option<f64> },
}
pub(crate) fn dim(size: f64, parent: usize, constraints: &[DimConstraint]) -> usize {
let parent_f = parent as f64;
let mut s = if size.abs() < 1.0 {
parent_f * size
} else {
size
};
if s < 0.0 {
s += parent_f;
}
for c in constraints {
match c {
DimConstraint::Fixed(v) => {
s = dim(*v, parent, &[]) as f64;
}
DimConstraint::Range { min, max } => {
let min_val = min.map(|m| dim(m, parent, &[]) as f64).unwrap_or(0.0);
let max_val = max.map(|m| dim(m, parent, &[]) as f64).unwrap_or(parent_f);
s = s.clamp(min_val, max_val);
}
}
}
(s.clamp(0.0, parent_f) + 0.5).floor() as usize
}
pub struct GridLayout {
pub columns: usize,
pub rows: usize,
pub column_width: usize,
}
impl GridLayout {
pub fn item_index(&self, col: usize, row: usize, item_count: usize) -> Option<usize> {
let idx = col * self.rows + row;
if idx < item_count {
Some(idx)
} else {
None
}
}
}
pub fn grid_layout(
item_count: usize,
max_entry_width: usize,
container_width: usize,
min_column_width: usize,
spacing: usize,
) -> Option<GridLayout> {
if item_count == 0 || container_width == 0 {
return None;
}
let box_width = dim(
max_entry_width as f64,
container_width,
&[DimConstraint::Range {
min: Some(min_column_width as f64),
max: None,
}],
);
let columns = ((container_width) / (box_width + spacing)).max(1);
let column_width = container_width / columns;
let rows = item_count.div_ceil(columns);
Some(GridLayout {
columns,
rows,
column_width,
})
}
pub fn truncate(s: &str, max_width: usize) -> Cow<'_, str> {
if max_width == 0 {
return Cow::Borrowed("");
}
let width = UnicodeWidthStr::width(s);
if width <= max_width {
return Cow::Borrowed(s);
}
let target = max_width.saturating_sub(1);
let mut current_width = 0;
let mut byte_end = 0;
for ch in s.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if current_width + ch_width > target {
break;
}
current_width += ch_width;
byte_end += ch.len_utf8();
}
let mut result = String::with_capacity(byte_end + 3);
result.push_str(&s[..byte_end]);
result.push('…');
Cow::Owned(result)
}
pub fn pad(s: &str, width: usize, align: Alignment) -> String {
let content_width = UnicodeWidthStr::width(s);
if content_width >= width {
return s.to_string();
}
let padding = width - content_width;
match align {
Alignment::Left => format!("{}{}", s, " ".repeat(padding)),
Alignment::Right => format!("{}{}", " ".repeat(padding), s),
Alignment::Center => {
let left = padding / 2;
let right = padding - left;
format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// === dim() tests ported from which-key.nvim layout_spec.lua ===
#[test]
fn dim_fixed_within_bounds() {
assert_eq!(dim(100.0, 200, &[]), 100);
}
#[test]
fn dim_percentage_20() {
assert_eq!(dim(0.2, 100, &[]), 20);
}
#[test]
fn dim_negative_percentage() {
assert_eq!(dim(-0.2, 100, &[]), 80);
}
#[test]
fn dim_negative_absolute() {
assert_eq!(dim(-20.0, 100, &[]), 80);
}
#[test]
fn dim_one() {
assert_eq!(dim(1.0, 100, &[]), 1);
}
#[test]
fn dim_with_min() {
assert_eq!(
dim(
100.0,
200,
&[DimConstraint::Range {
min: Some(50.0),
max: None
}]
),
100
);
}
#[test]
fn dim_with_max() {
assert_eq!(
dim(
100.0,
200,
&[DimConstraint::Range {
min: None,
max: Some(150.0)
}]
),
100
);
}
#[test]
fn dim_within_range() {
assert_eq!(
dim(
100.0,
200,
&[DimConstraint::Range {
min: Some(50.0),
max: Some(150.0)
}]
),
100
);
}
#[test]
fn dim_forced_to_min_equals_max() {
assert_eq!(
dim(
100.0,
200,
&[DimConstraint::Range {
min: Some(150.0),
max: Some(150.0)
}]
),
150
);
}
#[test]
fn dim_percentage_at_min() {
assert_eq!(
dim(
0.2,
100,
&[DimConstraint::Range {
min: Some(20.0),
max: Some(150.0)
}]
),
20
);
}
#[test]
fn dim_percentage_within_range() {
assert_eq!(
dim(
0.2,
100,
&[DimConstraint::Range {
min: Some(20.0),
max: Some(50.0)
}]
),
20
);
}
#[test]
fn dim_infinity_clamped() {
assert_eq!(dim(f64::MAX, 200, &[]), 200);
}
#[test]
fn dim_half_subtracted() {
assert_eq!(dim(-0.5, 200, &[]), 100);
}
#[test]
fn dim_half_of_parent() {
assert_eq!(dim(0.5, 200, &[]), 100);
}
#[test]
fn dim_percentage_below_min() {
assert_eq!(
dim(
0.5,
200,
&[DimConstraint::Range {
min: Some(150.0),
max: None
}]
),
150
);
}
#[test]
fn dim_subtraction_above_max() {
assert_eq!(
dim(
-0.5,
200,
&[DimConstraint::Range {
min: None,
max: Some(50.0)
}]
),
50
);
}
#[test]
fn dim_overflow_with_max() {
assert_eq!(
dim(
300.0,
200,
&[DimConstraint::Range {
min: None,
max: Some(250.0)
}]
),
200
);
}
#[test]
fn dim_overflow_with_min() {
assert_eq!(
dim(
300.0,
200,
&[DimConstraint::Range {
min: Some(250.0),
max: None
}]
),
200
);
}
#[test]
fn dim_underflow_to_min() {
assert_eq!(
dim(
-100.0,
100,
&[DimConstraint::Range {
min: Some(20.0),
max: Some(90.0)
}]
),
20
);
}
#[test]
fn dim_negative_constraints() {
assert_eq!(
dim(
-200.0,
100,
&[DimConstraint::Range {
min: Some(-50.0),
max: Some(-50.0)
}]
),
50
);
}
#[test]
fn dim_percentage_min() {
assert_eq!(
dim(
0.2,
100,
&[DimConstraint::Range {
min: Some(0.5),
max: None
}]
),
50
);
}
#[test]
fn dim_large_underflow() {
assert_eq!(dim(-200.0, 100, &[]), 0);
}
#[test]
fn dim_subtract_one() {
assert_eq!(dim(-1.0, 100, &[]), 99);
}
#[test]
fn dim_subtract_10_percent() {
assert_eq!(dim(-0.1, 100, &[]), 90);
}
#[test]
fn dim_10_percent() {
assert_eq!(dim(0.1, 100, &[]), 10);
}
#[test]
fn dim_fixed_constraint() {
assert_eq!(dim(14.0, 212, &[DimConstraint::Fixed(0.9)]), 191);
}
// === grid_layout tests ===
#[test]
fn grid_basic() {
let g = grid_layout(6, 20, 80, 20, 3).unwrap();
assert_eq!(g.columns, 3);
assert_eq!(g.rows, 2);
}
#[test]
fn grid_single_item() {
let g = grid_layout(1, 20, 80, 20, 3).unwrap();
assert_eq!(g.columns, 3);
assert_eq!(g.rows, 1);
}
#[test]
fn grid_narrow() {
let g = grid_layout(10, 30, 40, 20, 3).unwrap();
assert_eq!(g.columns, 1);
}
#[test]
fn grid_empty() {
assert!(grid_layout(0, 20, 80, 20, 3).is_none());
}
#[test]
fn grid_zero_width() {
assert!(grid_layout(5, 20, 0, 20, 3).is_none());
}
#[test]
fn grid_column_major_index() {
let g = GridLayout {
columns: 2,
rows: 3,
column_width: 30,
};
assert_eq!(g.item_index(0, 0, 6), Some(0));
assert_eq!(g.item_index(0, 1, 6), Some(1));
assert_eq!(g.item_index(0, 2, 6), Some(2));
assert_eq!(g.item_index(1, 0, 6), Some(3));
assert_eq!(g.item_index(1, 1, 6), Some(4));
assert_eq!(g.item_index(1, 2, 6), Some(5));
assert_eq!(g.item_index(2, 0, 6), None);
}
#[test]
fn grid_uneven_last_column() {
let g = GridLayout {
columns: 2,
rows: 3,
column_width: 30,
};
assert_eq!(g.item_index(1, 2, 5), None);
}
// === truncate tests ===
#[test]
fn truncate_fits() {
assert_eq!(truncate("hello world", 11).as_ref(), "hello world");
}
#[test]
fn truncate_one_short() {
assert_eq!(truncate("hello world", 10).as_ref(), "hello wor…");
}
#[test]
fn truncate_short() {
assert_eq!(truncate("hello world", 5).as_ref(), "hell…");
}
#[test]
fn truncate_minimal() {
assert_eq!(truncate("hello world", 1).as_ref(), "");
}
#[test]
fn truncate_zero() {
assert_eq!(truncate("hello world", 0).as_ref(), "");
}
#[test]
fn truncate_empty() {
assert_eq!(truncate("", 10).as_ref(), "");
}
#[test]
fn truncate_emoji() {
assert_eq!(truncate("🔥🔥🔥", 5).as_ref(), "🔥🔥…");
}
#[test]
fn truncate_cjk() {
assert_eq!(truncate("あいう", 5).as_ref(), "あい…");
}
#[test]
fn truncate_no_truncation_needed() {
assert_eq!(truncate("hello", 100).as_ref(), "hello");
}
// === pad tests ===
#[test]
fn pad_left() {
assert_eq!(pad("hi", 6, Alignment::Left), "hi ");
}
#[test]
fn pad_right() {
assert_eq!(pad("hi", 6, Alignment::Right), " hi");
}
#[test]
fn pad_center() {
assert_eq!(pad("hi", 6, Alignment::Center), " hi ");
}
#[test]
fn pad_center_odd() {
assert_eq!(pad("hi", 7, Alignment::Center), " hi ");
}
#[test]
fn pad_exact_fit() {
assert_eq!(pad("hi", 2, Alignment::Left), "hi");
}
#[test]
fn pad_too_narrow() {
assert_eq!(pad("hi", 1, Alignment::Left), "hi");
}
#[test]
fn pad_empty() {
assert_eq!(pad("", 5, Alignment::Left), " ");
}
}
+9
View File
@@ -0,0 +1,9 @@
pub mod hint;
pub mod layout;
pub mod render;
pub mod sort;
pub use hint::KeyHint;
pub use layout::GridLayout;
pub use render::{Position, WhichKey};
pub use sort::{default_sort_order, sort_hints, SortField};
+448
View File
@@ -0,0 +1,448 @@
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Padding, Widget},
};
use unicode_width::UnicodeWidthStr;
use crate::hint::KeyHint;
use crate::layout::{grid_layout, pad, truncate};
use crate::sort::{default_sort_order, sort_hints, SortField};
#[derive(Clone, Copy, Debug, Default)]
pub enum Position {
#[default]
BottomLeft,
BottomRight,
BottomCenter,
TopLeft,
TopRight,
TopCenter,
Center,
}
pub struct WhichKey {
hints: Vec<KeyHint>,
title: Option<String>,
position: Position,
separator: String,
group_prefix: String,
column_spacing: usize,
min_column_width: usize,
max_rows: usize,
padding: Padding,
key_style: Style,
separator_style: Style,
desc_style: Style,
group_style: Style,
border_style: Style,
bg: Option<Color>,
}
impl WhichKey {
pub fn new(hints: impl IntoIterator<Item = KeyHint>) -> Self {
let mut hints: Vec<KeyHint> = hints.into_iter().collect();
sort_hints(&mut hints, &default_sort_order());
Self {
hints,
title: None,
position: Position::default(),
separator: "".to_string(),
group_prefix: "+".to_string(),
column_spacing: 2,
min_column_width: 20,
max_rows: 10,
padding: Padding::new(1, 1, 0, 0),
key_style: Style::default().add_modifier(Modifier::BOLD),
separator_style: Style::default().fg(Color::DarkGray),
desc_style: Style::default(),
group_style: Style::default().fg(Color::Cyan),
border_style: Style::default(),
bg: None,
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn position(mut self, position: Position) -> Self {
self.position = position;
self
}
pub fn separator(mut self, separator: impl Into<String>) -> Self {
self.separator = separator.into();
self
}
pub fn group_prefix(mut self, prefix: impl Into<String>) -> Self {
self.group_prefix = prefix.into();
self
}
pub fn column_spacing(mut self, spacing: usize) -> Self {
self.column_spacing = spacing;
self
}
pub fn min_column_width(mut self, width: usize) -> Self {
self.min_column_width = width;
self
}
pub fn max_rows(mut self, rows: usize) -> Self {
self.max_rows = rows;
self
}
pub fn padding(mut self, padding: Padding) -> Self {
self.padding = padding;
self
}
pub fn key_style(mut self, style: Style) -> Self {
self.key_style = style;
self
}
pub fn separator_style(mut self, style: Style) -> Self {
self.separator_style = style;
self
}
pub fn desc_style(mut self, style: Style) -> Self {
self.desc_style = style;
self
}
pub fn group_style(mut self, style: Style) -> Self {
self.group_style = style;
self
}
pub fn border_style(mut self, style: Style) -> Self {
self.border_style = style;
self
}
pub fn bg(mut self, color: Color) -> Self {
self.bg = Some(color);
self
}
pub fn sort_fields(mut self, fields: Vec<SortField>) -> Self {
sort_hints(&mut self.hints, &fields);
self
}
fn max_entry_width(&self) -> usize {
let sep_width = UnicodeWidthStr::width(self.separator.as_str());
self.hints
.iter()
.map(|h| {
let key_width = UnicodeWidthStr::width(h.key.as_str());
let desc_width = UnicodeWidthStr::width(h.description.as_str());
key_width + sep_width + desc_width
})
.max()
.unwrap_or(0)
}
fn padding_horizontal(&self) -> u16 {
self.padding.left + self.padding.right
}
fn padding_vertical(&self) -> u16 {
self.padding.top + self.padding.bottom
}
pub fn layout(&self, available: Rect) -> Rect {
if self.hints.is_empty() || available.width == 0 || available.height == 0 {
return Rect::default();
}
let border_size: u16 = 2;
let inner_width = available
.width
.saturating_sub(border_size + self.padding_horizontal());
if inner_width == 0 {
return Rect::default();
}
let max_entry = self.max_entry_width();
let grid = match grid_layout(
self.hints.len(),
max_entry,
inner_width as usize,
self.min_column_width,
self.column_spacing,
) {
Some(g) => g,
None => return Rect::default(),
};
let rows = grid.rows.min(self.max_rows);
let popup_height = (rows as u16)
.saturating_add(border_size)
.saturating_add(self.padding_vertical())
.min(available.height);
let content_width = (grid.column_width * grid.columns) as u16;
let popup_width = content_width
.saturating_add(border_size)
.saturating_add(self.padding_horizontal())
.min(available.width);
let (x, y) = match self.position {
Position::BottomLeft => (available.x, available.bottom().saturating_sub(popup_height)),
Position::BottomRight => (
available.right().saturating_sub(popup_width),
available.bottom().saturating_sub(popup_height),
),
Position::BottomCenter => (
available.x + (available.width.saturating_sub(popup_width)) / 2,
available.bottom().saturating_sub(popup_height),
),
Position::TopLeft => (available.x, available.y),
Position::TopRight => (available.right().saturating_sub(popup_width), available.y),
Position::TopCenter => (
available.x + (available.width.saturating_sub(popup_width)) / 2,
available.y,
),
Position::Center => (
available.x + (available.width.saturating_sub(popup_width)) / 2,
available.y + (available.height.saturating_sub(popup_height)) / 2,
),
};
Rect::new(x, y, popup_width, popup_height)
}
}
impl Widget for &WhichKey {
fn render(self, area: Rect, buf: &mut Buffer) {
if self.hints.is_empty() || area.width == 0 || area.height == 0 {
return;
}
Clear.render(area, buf);
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(self.border_style)
.padding(self.padding);
if let Some(ref title) = self.title {
block = block.title(title.as_str());
}
if let Some(bg) = self.bg {
block = block.style(Style::default().bg(bg));
}
let inner = block.inner(area);
block.render(area, buf);
if inner.width == 0 || inner.height == 0 {
return;
}
let max_entry = self.max_entry_width();
let grid = match grid_layout(
self.hints.len(),
max_entry,
inner.width as usize,
self.min_column_width,
self.column_spacing,
) {
Some(g) => g,
None => return,
};
let sep_width = UnicodeWidthStr::width(self.separator.as_str());
let max_key_width = self
.hints
.iter()
.map(|h| UnicodeWidthStr::width(h.key.as_str()))
.max()
.unwrap_or(0);
for row in 0..grid.rows.min(self.max_rows) {
let y = inner.y + row as u16;
if y >= inner.bottom() {
break;
}
for col in 0..grid.columns {
let Some(idx) = grid.item_index(col, row, self.hints.len()) else {
continue;
};
let hint = &self.hints[idx];
let x = inner.x + (col * grid.column_width) as u16;
if x >= inner.right() {
break;
}
let available_width = (inner.right().saturating_sub(x) as usize)
.min(grid.column_width.saturating_sub(self.column_spacing));
let key_padded = pad(&hint.key, max_key_width, Alignment::Right);
let desc_max_width = available_width
.saturating_sub(max_key_width)
.saturating_sub(sep_width);
let desc_truncated = truncate(&hint.description, desc_max_width);
let desc_style = if hint.is_group {
self.group_style
} else {
self.desc_style
};
let line = Line::from(vec![
Span::styled(&key_padded, self.key_style),
Span::styled(&self.separator, self.separator_style),
Span::styled(desc_truncated.into_owned(), desc_style),
]);
buf.set_line(x, y, &line, available_width as u16);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_layout() {
let w = WhichKey::new(vec![]);
assert_eq!(w.layout(Rect::new(0, 0, 80, 24)), Rect::default());
}
#[test]
fn test_zero_area_layout() {
let w = WhichKey::new(vec![KeyHint::new("a", "test")]);
assert_eq!(w.layout(Rect::new(0, 0, 0, 0)), Rect::default());
}
#[test]
fn test_basic_layout_bottom_left() {
let hints = vec![KeyHint::new("a", "action"), KeyHint::new("b", "buffer")];
let w = WhichKey::new(hints).position(Position::BottomLeft);
let rect = w.layout(Rect::new(0, 0, 80, 24));
assert!(rect.y > 0);
assert_eq!(rect.x, 0);
assert!(rect.width > 0);
assert!(rect.height > 0);
}
#[test]
fn test_render_no_panic_empty() {
let w = WhichKey::new(vec![]);
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 24));
(&w).render(Rect::default(), &mut buf);
}
#[test]
fn test_render_no_panic_zero_area() {
let w = WhichKey::new(vec![KeyHint::new("a", "test")]);
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 24));
(&w).render(Rect::new(0, 0, 0, 0), &mut buf);
}
#[test]
fn test_render_basic() {
let hints = vec![
KeyHint::new("b", "+buffer").group(),
KeyHint::new("h", "help"),
KeyHint::new("q", "quit"),
];
let w = WhichKey::new(hints).title("SPC");
let area = Rect::new(0, 0, 60, 20);
let popup_rect = w.layout(area);
let mut buf = Buffer::empty(area);
(&w).render(popup_rect, &mut buf);
let has_content = (popup_rect.y..popup_rect.bottom()).any(|y| {
(popup_rect.x..popup_rect.right())
.any(|x| buf.cell((x, y)).is_some_and(|c| c.symbol() != " "))
});
assert!(has_content);
}
#[test]
fn test_builder_methods() {
let w = WhichKey::new(vec![KeyHint::new("a", "test")])
.title("Test")
.position(Position::Center)
.separator(" -> ")
.group_prefix("+")
.column_spacing(4)
.min_column_width(15)
.max_rows(5)
.padding(Padding::new(2, 2, 1, 1))
.key_style(Style::default().fg(Color::Red))
.separator_style(Style::default().fg(Color::Blue))
.desc_style(Style::default().fg(Color::Green))
.group_style(Style::default().fg(Color::Yellow))
.border_style(Style::default().fg(Color::White))
.bg(Color::Black);
assert!(w.title.is_some());
assert!(matches!(w.position, Position::Center));
}
#[test]
fn test_sort_fields_builder() {
let hints = vec![
KeyHint::new("G", "last"),
KeyHint::new("g", "goto"),
KeyHint::new("a", "action"),
];
let w = WhichKey::new(hints).sort_fields(vec![SortField::Case]);
assert_eq!(w.hints[0].key, "a");
assert_eq!(w.hints[1].key, "g");
assert_eq!(w.hints[2].key, "G");
}
#[test]
fn test_position_variants() {
let hints = vec![KeyHint::new("a", "action")];
let area = Rect::new(0, 0, 80, 24);
let positions = [
Position::BottomLeft,
Position::BottomRight,
Position::BottomCenter,
Position::TopLeft,
Position::TopRight,
Position::TopCenter,
Position::Center,
];
for pos in positions {
let w = WhichKey::new(hints.clone()).position(pos);
let rect = w.layout(area);
assert!(rect.width > 0);
assert!(rect.height > 0);
assert!(rect.x < area.right());
assert!(rect.y < area.bottom());
}
}
#[test]
fn test_max_rows_clamping() {
let hints: Vec<KeyHint> = (0..20).map(|i| KeyHint::new(format!("{i}"), "desc")).collect();
let w = WhichKey::new(hints).max_rows(5);
let rect = w.layout(Rect::new(0, 0, 80, 50));
assert!(rect.height <= 10);
}
}
+186
View File
@@ -0,0 +1,186 @@
use crate::hint::KeyHint;
#[derive(Clone, Copy, Debug)]
pub enum SortField {
Group,
Alphanum,
Natural,
Case,
}
pub fn default_sort_order() -> Vec<SortField> {
vec![
SortField::Group,
SortField::Alphanum,
SortField::Natural,
SortField::Case,
]
}
pub fn sort_hints(hints: &mut [KeyHint], fields: &[SortField]) {
hints.sort_by(|a, b| {
for field in fields {
let ord = compare_field(a, b, *field);
if ord != std::cmp::Ordering::Equal {
return ord;
}
}
a.key.cmp(&b.key)
});
}
fn compare_field(a: &KeyHint, b: &KeyHint, field: SortField) -> std::cmp::Ordering {
match field {
SortField::Group => {
let a_val = if a.is_group { 1 } else { 0 };
let b_val = if b.is_group { 1 } else { 0 };
a_val.cmp(&b_val)
}
SortField::Alphanum => {
let a_val = if a.key.chars().all(|c| c.is_alphanumeric()) {
0
} else {
1
};
let b_val = if b.key.chars().all(|c| c.is_alphanumeric()) {
0
} else {
1
};
a_val.cmp(&b_val)
}
SortField::Natural => {
let a_natural = natural_key(&a.key);
let b_natural = natural_key(&b.key);
a_natural.cmp(&b_natural)
}
SortField::Case => {
let a_val = if a.key.chars().next().is_some_and(|c| c.is_lowercase()) {
0
} else {
1
};
let b_val = if b.key.chars().next().is_some_and(|c| c.is_lowercase()) {
0
} else {
1
};
a_val.cmp(&b_val)
}
}
}
fn natural_key(s: &str) -> String {
let mut result = String::new();
let mut num_buf = String::new();
for ch in s.chars() {
if ch.is_ascii_digit() {
num_buf.push(ch);
} else {
if !num_buf.is_empty() {
result.push_str(&format!("{:0>9}", num_buf));
num_buf.clear();
}
result.push(ch.to_ascii_lowercase());
}
}
if !num_buf.is_empty() {
result.push_str(&format!("{:0>9}", num_buf));
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sort_groups_last() {
let mut hints = vec![
KeyHint::new("z", "action").group(),
KeyHint::new("a", "cmd"),
KeyHint::new("m", "other"),
];
sort_hints(&mut hints, &default_sort_order());
assert_eq!(hints[0].key, "a");
assert_eq!(hints[1].key, "m");
assert!(hints[2].is_group);
}
#[test]
fn sort_alphanum_before_special() {
let mut hints = vec![
KeyHint::new("<C-d>", "page down"),
KeyHint::new("j", "down"),
KeyHint::new("<Esc>", "escape"),
];
sort_hints(&mut hints, &[SortField::Alphanum]);
assert_eq!(hints[0].key, "j");
}
#[test]
fn sort_natural_numbers() {
let mut hints = vec![
KeyHint::new("F10", "func"),
KeyHint::new("F2", "func"),
KeyHint::new("F1", "func"),
];
sort_hints(&mut hints, &[SortField::Natural]);
assert_eq!(hints[0].key, "F1");
assert_eq!(hints[1].key, "F2");
assert_eq!(hints[2].key, "F10");
}
#[test]
fn sort_case() {
let mut hints = vec![KeyHint::new("G", "last"), KeyHint::new("g", "goto")];
sort_hints(&mut hints, &[SortField::Case]);
assert_eq!(hints[0].key, "g");
assert_eq!(hints[1].key, "G");
}
#[test]
fn sort_combined_default() {
let mut hints = vec![
KeyHint::new("G", "last"),
KeyHint::new("b", "buffer").group(),
KeyHint::new("a", "action"),
KeyHint::new("1", "tab1"),
KeyHint::new("<C-d>", "page down"),
];
sort_hints(&mut hints, &default_sort_order());
assert_eq!(hints[0].key, "1");
assert_eq!(hints[1].key, "a");
assert!(!hints[0].is_group);
assert!(hints.last().unwrap().is_group);
}
#[test]
fn default_sort_order_has_all_fields() {
let order = default_sort_order();
assert_eq!(order.len(), 4);
}
#[test]
fn sort_empty() {
let mut hints: Vec<KeyHint> = vec![];
sort_hints(&mut hints, &default_sort_order());
assert!(hints.is_empty());
}
#[test]
fn sort_single() {
let mut hints = vec![KeyHint::new("a", "action")];
sort_hints(&mut hints, &default_sort_order());
assert_eq!(hints.len(), 1);
assert_eq!(hints[0].key, "a");
}
#[test]
fn natural_key_padding() {
assert_eq!(natural_key("F1"), "f000000001");
assert_eq!(natural_key("F10"), "f000000010");
assert_eq!(natural_key("item123abc"), "item000000123abc");
}
}
+136 -31
View File
@@ -2,16 +2,101 @@
use crossterm::event::MouseButton;
use ratatui::widgets::ListState;
use tokio::sync::mpsc::Sender;
use crate::application::app_state::App;
use crate::data::{Artist, Track};
use crate::domain::conversions::{convert_artist, convert_track};
use crate::domain::navigation::Tab;
use crate::grpc::GrpcResponse;
use crate::domain::conversions::{convert_album, convert_artist, convert_track};
use crate::domain::navigation::{ModalKind, Tab};
use crate::grpc::{GrpcRequest, GrpcResponse};
use crate::input::AppAction;
use crate::ui::library::{LibraryFocus, LibraryState};
use crate::ui::notifications::NotifKind;
impl App {
pub fn handle_action(&mut self, action: AppAction, count: usize, tx: &Sender<GrpcRequest>) {
match action {
AppAction::MoveDown => {
for _ in 0..count {
self.library.move_down();
}
}
AppAction::MoveUp => {
for _ in 0..count {
self.library.move_up();
}
}
AppAction::FocusLeft => self.library.focus_left(),
AppAction::FocusRight => self.library.focus_right(),
AppAction::FocusDown => self.library.focus_down(),
AppAction::FocusUp => self.library.focus_up(),
AppAction::GotoFirst => match self.library.focus {
LibraryFocus::Artists => self.library.artist_state.select(Some(0)),
LibraryFocus::Albums => self.library.album_state.select(Some(0)),
LibraryFocus::Tracks => self.library.track_state.select(Some(0)),
},
AppAction::GotoLast => match self.library.focus {
LibraryFocus::Artists => {
let last = self.library.artists.len().saturating_sub(1);
self.library.artist_state.select(Some(last));
}
LibraryFocus::Albums => {
if let Some(artist) = self.library.selected_artist() {
let last = artist.albums.len().saturating_sub(1);
self.library.album_state.select(Some(last));
}
}
LibraryFocus::Tracks => {
let last = self.library.tracks.len().saturating_sub(1);
self.library.track_state.select(Some(last));
}
},
AppAction::HalfPageDown => {
for _ in 0..15 {
self.library.move_down();
}
}
AppAction::HalfPageUp => {
for _ in 0..15 {
self.library.move_up();
}
}
AppAction::NextTab => {
let idx = self.tab.index();
let next = (idx + 1) % Tab::ALL.len();
self.tab = Tab::ALL[next];
}
AppAction::PrevTab => {
let idx = self.tab.index();
let prev = if idx == 0 {
Tab::ALL.len() - 1
} else {
idx - 1
};
self.tab = Tab::ALL[prev];
}
AppAction::GotoTab(i) => {
if let Some(tab) = Tab::ALL.get(i) {
self.tab = *tab;
}
}
AppAction::Quit => self.running = false,
AppAction::Refresh => {
self.library.clear_cache();
let _ = tx.try_send(GrpcRequest::GetArtists);
}
AppAction::ShowHelp => self.modal = Some(ModalKind::Help),
AppAction::ToggleNotifications => {
self.notifications_open = !self.notifications_open;
if self.notifications_open {
self.notifications_scroll = 0;
self.notifications_expanded = None;
}
}
AppAction::Escape => self.handle_escape(),
}
}
pub fn handle_escape(&mut self) {
if self.notifications_open {
self.notifications_open = false;
@@ -111,7 +196,7 @@ impl App {
match self.tab {
Tab::Library => {
self.handle_library_click(x, rel_y);
self.handle_library_click(x, y);
}
Tab::Wanted => {
self.select_list_item(&mut self.wanted_state.clone(), self.wanted.len(), rel_y);
@@ -133,45 +218,48 @@ impl App {
}
}
fn handle_library_click(&mut self, x: u16, rel_y: usize) {
const ARTISTS_PANE_WIDTH: u16 = 32;
const BORDER_TOP: usize = 1;
const HEADER_HEIGHT: usize = 6;
const DIVIDER_HEIGHT: usize = 1;
const ALBUMS_START_ROW: usize = BORDER_TOP + HEADER_HEIGHT + DIVIDER_HEIGHT;
fn handle_library_click(&mut self, x: u16, y: u16) {
let artists = self.library.artists_inner_area;
let albums = self.library.albums_inner_area;
let tracks = self.library.tracks_inner_area;
if x < ARTISTS_PANE_WIDTH {
if rel_y > 0 && rel_y <= self.library.artists.len() {
self.library.artist_state.select(Some(rel_y - 1));
if x >= artists.x
&& x < artists.x + artists.width
&& y >= artists.y
&& y < artists.y + artists.height
{
let row = (y - artists.y) as usize;
if row < self.library.artists.len() {
self.library.artist_state.select(Some(row));
self.library.album_state.select(Some(0));
self.library.track_state.select(Some(0));
self.library.focus = LibraryFocus::Artists;
}
} else if rel_y >= ALBUMS_START_ROW {
let album_row = rel_y - ALBUMS_START_ROW;
let content_height = self.main_area.height.saturating_sub(10) as usize;
let albums_section_height = (content_height * 40) / 100;
let tracks_start_row = ALBUMS_START_ROW + albums_section_height + DIVIDER_HEIGHT;
if rel_y < tracks_start_row {
if let Some(artist) = self.library.selected_artist()
&& album_row < artist.albums.len()
} else if x >= albums.x
&& x < albums.x + albums.width
&& y >= albums.y
&& y < albums.y + albums.height
{
self.library.album_state.select(Some(album_row));
let row = (y - albums.y) as usize;
if let Some(artist) = self.library.selected_artist()
&& row < artist.albums.len()
{
self.library.album_state.select(Some(row));
self.library.track_state.select(Some(0));
self.library.focus = LibraryFocus::Albums;
}
} else {
let track_row = rel_y - tracks_start_row;
if let Some(album) = self.library.selected_album()
&& track_row < album.total as usize
} else if x >= tracks.x
&& x < tracks.x + tracks.width
&& y >= tracks.y
&& y < tracks.y + tracks.height
{
self.library.track_state.select(Some(track_row));
let row = (y - tracks.y) as usize;
if row < self.library.tracks.len() {
self.library.track_state.select(Some(row));
self.library.focus = LibraryFocus::Tracks;
}
}
}
}
fn select_list_item(&self, _state: &mut ListState, _len: usize, _rel_y: usize) {}
@@ -288,8 +376,25 @@ impl App {
);
}
GrpcResponse::Album { album, tracks } => {
let converted: Vec<Track> = tracks.into_iter().map(convert_track).collect();
self.library.cache_tracks(album.id, converted);
let album_downloaded = album
.download
.as_ref()
.is_some_and(|d| matches!(d.state.as_str(), "completed" | "downloaded"));
let album_quality = album
.download
.as_ref()
.map(|d| d.format.as_str())
.filter(|f| !f.is_empty())
.unwrap_or("")
.to_string();
let album_id = album.id.clone();
let updated = convert_album(album);
self.library.update_album(updated);
let converted: Vec<Track> = tracks
.into_iter()
.map(|t| convert_track(t, album_downloaded, &album_quality))
.collect();
self.library.cache_tracks(album_id, converted);
}
GrpcResponse::Error(msg) => {
self.set_error(msg);
+34 -14
View File
@@ -2,6 +2,7 @@
use std::collections::HashMap;
use ratatui::layout::Rect;
use ratatui::widgets::ListState;
use crate::data::{Album, Artist, Track};
@@ -21,6 +22,9 @@ pub struct LibraryState {
pub artist_state: ListState,
pub album_state: ListState,
pub track_state: ListState,
pub(crate) artists_inner_area: Rect,
pub(crate) albums_inner_area: Rect,
pub(crate) tracks_inner_area: Rect,
tracks_cache: HashMap<String, Vec<Track>>,
pending_album_id: Option<String>,
}
@@ -46,6 +50,9 @@ impl LibraryState {
artist_state,
album_state,
track_state,
artists_inner_area: Rect::default(),
albums_inner_area: Rect::default(),
tracks_inner_area: Rect::default(),
tracks_cache: HashMap::new(),
pending_album_id: None,
}
@@ -131,8 +138,9 @@ impl LibraryState {
pub fn focus_left(&mut self) {
match self.focus {
LibraryFocus::Artists => {}
LibraryFocus::Albums => self.focus = LibraryFocus::Artists,
LibraryFocus::Tracks => self.focus = LibraryFocus::Albums,
LibraryFocus::Albums | LibraryFocus::Tracks => {
self.focus = LibraryFocus::Artists;
}
}
}
@@ -143,21 +151,20 @@ impl LibraryState {
self.focus = LibraryFocus::Albums;
}
}
LibraryFocus::Albums => {
if self.selected_album().is_some() {
self.focus = LibraryFocus::Tracks;
}
}
LibraryFocus::Tracks => {}
LibraryFocus::Albums | LibraryFocus::Tracks => {}
}
}
pub fn cycle_focus(&mut self) {
self.focus = match self.focus {
LibraryFocus::Artists => LibraryFocus::Albums,
LibraryFocus::Albums => LibraryFocus::Tracks,
LibraryFocus::Tracks => LibraryFocus::Artists,
};
pub fn focus_down(&mut self) {
if self.focus == LibraryFocus::Albums {
self.focus = LibraryFocus::Tracks;
}
}
pub fn focus_up(&mut self) {
if self.focus == LibraryFocus::Tracks {
self.focus = LibraryFocus::Albums;
}
}
fn reset_album_selection(&mut self) {
@@ -242,6 +249,19 @@ impl LibraryState {
self.selected_album().map(|a| a.id.clone())
}
pub fn update_album(&mut self, updated: Album) {
for artist in &mut self.artists {
if let Some(album) = artist.albums.iter_mut().find(|a| a.id == updated.id) {
album.have = updated.have;
album.total = updated.total;
album.status = updated.status;
album.quality = updated.quality.clone();
album.monitored = updated.monitored;
return;
}
}
}
pub fn clear_cache(&mut self) {
self.tracks_cache.clear();
self.pending_album_id = None;
+39 -21
View File
@@ -21,27 +21,32 @@ pub fn convert_album(detail: AlbumDetail) -> Album {
let monitor_state = MonitorState::from_proto(detail.monitor_state);
let monitored = monitor_state.is_monitored();
let (have, status, quality) = if let Some(download) = detail.download {
let have = if download.state == "downloaded" {
detail.total_tracks as u16
let (have, status, quality) = if let Some(download) = &detail.download {
let is_completed = matches!(download.state.as_str(), "completed" | "downloaded");
let have = if is_completed {
detail
.release
.as_ref()
.map(|r| r.track_count as u16)
.unwrap_or(detail.total_tracks as u16)
} else {
0
};
let status = match download.state.as_str() {
"downloaded" => AlbumStatus::Complete,
"downloading" => AlbumStatus::Partial,
_ => {
if monitored {
let status = if is_completed {
AlbumStatus::Complete
} else if download.state == "downloading" {
AlbumStatus::Partial
} else if monitored {
AlbumStatus::Wanted
} else {
AlbumStatus::Unmonitored
}
}
};
let quality = if download.quality.is_empty() {
"".to_string()
let quality = if !download.format.is_empty() {
download.format.clone()
} else if !download.quality.is_empty() {
download.quality.clone()
} else {
download.quality
"".to_string()
};
(have, status, quality)
} else {
@@ -53,13 +58,23 @@ pub fn convert_album(detail: AlbumDetail) -> Album {
(0, status, "".to_string())
};
let total = if detail.total_tracks > 0 {
detail.total_tracks as u16
} else {
detail
.release
.as_ref()
.map(|r| r.track_count as u16)
.unwrap_or(0)
};
Album {
id: detail.id,
title: detail.title,
year,
album_type: detail.album_type,
monitored,
total: detail.total_tracks as u16,
total,
have,
quality,
status,
@@ -74,13 +89,16 @@ pub fn parse_year(date_str: &str) -> u16 {
.unwrap_or(0)
}
pub fn convert_track(detail: TrackDetail) -> Track {
let have = detail.file.is_some();
let quality = detail
.file
.as_ref()
.map(|f| f.format.clone())
.unwrap_or_else(|| "".to_string());
pub fn convert_track(detail: TrackDetail, album_downloaded: bool, album_quality: &str) -> Track {
let has_file = !detail.file_path.is_empty();
let have = has_file || album_downloaded;
let quality = if !detail.format.is_empty() {
detail.format
} else if album_downloaded {
album_quality.to_string()
} else {
"".to_string()
};
let duration = format_duration(detail.duration_ms);
Track {
+2 -2
View File
@@ -1,4 +1,4 @@
pub mod aggregates;
pub mod conversions;
pub mod models;
pub mod navigation;
pub mod conversions;
pub mod aggregates;
+5 -2
View File
@@ -51,10 +51,13 @@ impl GrpcClient {
) -> Result<(AlbumDetail, Vec<TrackDetail>), tonic::Status> {
let response = self.music.get_album(GetAlbumRequest { album_id }).await?;
let inner = response.into_inner();
let album = inner
let info = inner
.info
.ok_or_else(|| tonic::Status::not_found("Album info not found in response"))?;
let album = info
.album
.ok_or_else(|| tonic::Status::not_found("Album not found in response"))?;
Ok((album, inner.tracks))
Ok((album, info.tracks))
}
}
+26
View File
@@ -0,0 +1,26 @@
#[derive(Clone, Debug)]
pub enum AppAction {
// Movement
MoveUp,
MoveDown,
FocusLeft,
FocusRight,
FocusDown,
FocusUp,
GotoFirst,
GotoLast,
HalfPageDown,
HalfPageUp,
// Tabs
NextTab,
PrevTab,
GotoTab(usize),
// System
Quit,
Refresh,
ShowHelp,
ToggleNotifications,
Escape,
}
+68
View File
@@ -0,0 +1,68 @@
use evil_keys::KeyTrie;
use crate::input::action::AppAction;
pub fn build_normal_keymap() -> KeyTrie<AppAction> {
let mut t = KeyTrie::new("normal");
t.bind_desc("j", AppAction::MoveDown, "down").unwrap();
t.bind_desc("k", AppAction::MoveUp, "up").unwrap();
t.bind_desc("h", AppAction::FocusLeft, "focus left")
.unwrap();
t.bind_desc("l", AppAction::FocusRight, "focus right")
.unwrap();
t.bind_desc("Tab", AppAction::FocusRight, "next pane")
.unwrap();
t.bind_desc("G", AppAction::GotoLast, "last item").unwrap();
t.bind_desc("C-d", AppAction::HalfPageDown, "half page down")
.unwrap();
t.bind_desc("C-u", AppAction::HalfPageUp, "half page up")
.unwrap();
t.group("g", "+goto", |g| {
g.bind_desc("g", AppAction::GotoFirst, "first item")?;
g.bind_desc("t", AppAction::NextTab, "next tab")?;
g.bind_desc("T", AppAction::PrevTab, "prev tab")?;
Ok(())
})
.unwrap();
for i in 1..=6 {
t.bind(&format!("{i}"), AppAction::GotoTab(i - 1)).unwrap();
}
t.group("SPC", "+leader", |g| {
g.group("b", "+buffer", |b| {
b.bind_desc("l", AppAction::GotoTab(0), "library")?;
b.bind_desc("w", AppAction::GotoTab(1), "wanted")?;
b.bind_desc("q", AppAction::GotoTab(2), "queue")?;
b.bind_desc("h", AppAction::GotoTab(3), "history")?;
b.bind_desc("n", AppAction::NextTab, "next")?;
b.bind_desc("p", AppAction::PrevTab, "prev")?;
Ok(())
})?;
g.group("w", "+window", |w| {
w.bind_desc("h", AppAction::FocusLeft, "pane left")?;
w.bind_desc("l", AppAction::FocusRight, "pane right")?;
w.bind_desc("j", AppAction::FocusDown, "pane down")?;
w.bind_desc("k", AppAction::FocusUp, "pane up")?;
Ok(())
})?;
g.bind_desc("h", AppAction::ShowHelp, "help")?;
g.bind_desc("q", AppAction::Quit, "quit")?;
g.bind_desc("n", AppAction::ToggleNotifications, "notifications")?;
Ok(())
})
.unwrap();
t.bind("Esc", AppAction::Escape).unwrap();
t.bind("?", AppAction::ShowHelp).unwrap();
t
}
pub fn build_insert_keymap() -> KeyTrie<AppAction> {
let mut t = KeyTrie::new("insert");
t.bind("Esc", AppAction::Escape).unwrap();
t
}
+5
View File
@@ -0,0 +1,5 @@
pub mod action;
pub mod keymap;
pub use action::AppAction;
pub use keymap::{build_insert_keymap, build_normal_keymap};
+1
View File
@@ -5,6 +5,7 @@ pub mod data;
pub mod domain;
pub mod grpc;
pub mod infrastructure;
pub mod input;
pub mod presentation;
pub mod proto;
pub mod theme;
+35 -10
View File
@@ -11,11 +11,14 @@ use crossterm::{
},
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use evil_keys::{DispatchResult, Dispatcher};
use ratatui::prelude::*;
use ui_agregator::app::App;
use ui_agregator::config::Config;
use ui_agregator::grpc::{GrpcRequest, spawn_grpc_worker};
use ui_agregator::input::{build_insert_keymap, build_normal_keymap};
use ui_agregator::presentation::which_key_popup::render_which_key_popup;
const TICK_RATE: Duration = Duration::from_millis(100);
@@ -59,6 +62,16 @@ async fn run() -> Result<()> {
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
let mut app = App::new();
let mut dispatcher = Dispatcher::new();
dispatcher
.add_mode("normal", build_normal_keymap())
.unwrap();
dispatcher
.add_mode("insert", build_insert_keymap())
.unwrap();
dispatcher.set_active("normal").unwrap();
dispatcher.set_timeout(Duration::from_millis(1000));
let (grpc_tx, mut grpc_rx) = spawn_grpc_worker(config.grpc_addr());
if grpc_tx.try_send(GrpcRequest::GetArtists).is_err() {
@@ -66,7 +79,14 @@ async fn run() -> Result<()> {
}
while app.running {
terminal.draw(|frame| app.draw(frame))?;
let which_key_entries = dispatcher.which_key_entries();
let pending_display = dispatcher.pending_display();
terminal.draw(|frame| {
app.draw(frame);
if let Some(ref entries) = which_key_entries {
render_which_key_popup(frame, frame.area(), entries, &pending_display);
}
})?;
if let Ok(response) = grpc_rx.try_recv() {
app.handle_grpc_response(response);
@@ -78,18 +98,23 @@ async fn run() -> Result<()> {
if event::poll(TICK_RATE)? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
if key.modifiers.contains(KeyModifiers::CONTROL)
Event::Key(key) => {
if key.kind == KeyEventKind::Press
&& key.modifiers.contains(KeyModifiers::CONTROL)
&& key.code == KeyCode::Char('c')
{
app.running = false;
} else if key.code == KeyCode::Esc {
app.handle_escape();
} else if key.code == KeyCode::Char('r')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
app.library.clear_cache();
let _ = grpc_tx.try_send(GrpcRequest::GetArtists);
} else {
match dispatcher.dispatch(key) {
DispatchResult::Matched { action, count } => {
app.handle_action(action, count, &grpc_tx);
}
DispatchResult::Pending
| DispatchResult::Cancelled
| DispatchResult::CountAccumulated
| DispatchResult::Ignored
| DispatchResult::NotFound => {}
}
}
}
Event::Mouse(mouse) => match mouse.kind {
+37 -56
View File
@@ -12,7 +12,7 @@ use crate::application::library_state::{LibraryFocus, LibraryState};
use crate::data::{Album, AlbumStatus, Artist, MonitorState};
use crate::domain::aggregates::artist_status;
use crate::theme;
use crate::ui::pane::{Pane, section_divider};
use crate::ui::pane::Pane;
use crate::ui::progress_bar::progress_bar;
fn status_icon(status: AlbumStatus, monitored: bool) -> (char, Style) {
@@ -64,7 +64,7 @@ pub fn render_library(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
render_detail_pane(frame, chunks[1], state);
}
fn render_artists_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
pub fn render_artists_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
let focused = state.focus == LibraryFocus::Artists;
let artist_count = state.artists.len();
@@ -89,6 +89,7 @@ fn render_artists_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState)
let block = pane.build_block();
let inner = block.inner(area);
frame.render_widget(block, area);
state.artists_inner_area = inner;
let items: Vec<ListItem> = state
.artists
@@ -131,70 +132,40 @@ fn render_artists_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState)
frame.render_stateful_widget(list, inner, &mut state.artist_state);
}
fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
let focused = state.focus == LibraryFocus::Albums || state.focus == LibraryFocus::Tracks;
pub fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
frame.render_widget(
Paragraph::new("").style(Style::default().bg(theme::BG0)),
area,
);
let artist = state.selected_artist();
let meta = artist
.map(|a| {
format!(
"{} · {}",
a.country,
a.genres.first().map(|s| s.as_str()).unwrap_or("")
)
})
.unwrap_or_default();
let have_tracks: u16 = artist
.map(|a| a.albums.iter().map(|al| al.have).sum())
.unwrap_or(0);
let total_tracks: u16 = artist
.map(|a| a.albums.iter().map(|al| al.total).sum())
.unwrap_or(0);
let footer = if artist.is_some() {
Line::from(vec![Span::styled(
format!("{}/{} tracks", have_tracks, total_tracks),
Style::default().fg(theme::GRAY),
)])
} else {
Line::from("")
};
let pane = Pane::new("Detail")
.meta(&meta)
.focused(focused)
.footer(footer);
let block = pane.build_block();
let inner = block.inner(area);
frame.render_widget(block, area);
let Some(artist) = artist else {
let Some(artist) = state.selected_artist() else {
let msg = Paragraph::new(Span::styled(
"No artist selected",
Style::default().fg(theme::GRAY),
));
frame.render_widget(msg, inner);
frame.render_widget(msg, area);
return;
};
let chunks = Layout::vertical([
Constraint::Length(6),
Constraint::Length(1),
Constraint::Percentage(40),
Constraint::Length(1),
Constraint::Fill(1),
])
.split(inner);
.split(area);
render_artist_header(frame, chunks[0], artist);
let albums_count = artist.albums.len();
let albums_label = format!("{} releases", albums_count);
let album_divider = section_divider("albums", Some(&albums_label));
frame.render_widget(Paragraph::new(album_divider), chunks[1]);
let albums_focused = state.focus == LibraryFocus::Albums;
let albums_pane = Pane::new("Albums")
.meta(&albums_label)
.focused(albums_focused);
let albums_block = albums_pane.build_block();
let albums_inner = albums_block.inner(chunks[1]);
frame.render_widget(albums_block, chunks[1]);
state.albums_inner_area = albums_inner;
let selected_artist_idx = state.artist_state.selected();
if let Some(idx) = selected_artist_idx
@@ -202,7 +173,7 @@ fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
{
let albums = artist.albums.clone();
let focus = state.focus;
render_albums_list(frame, chunks[2], &albums, focus, &mut state.album_state);
render_albums_list(frame, albums_inner, &albums, focus, &mut state.album_state);
}
let album_title = state
@@ -213,14 +184,24 @@ fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
.selected_album()
.map(|a| format!("{}/{}", a.have, a.total))
.unwrap_or_default();
let track_label = format!("tracks · {}", album_title);
let track_divider = section_divider(&track_label, Some(&track_counts));
frame.render_widget(Paragraph::new(track_divider), chunks[3]);
let track_meta = if album_title.is_empty() {
String::new()
} else {
format!("{} · {}", album_title, track_counts)
};
let tracks_focused = state.focus == LibraryFocus::Tracks;
let tracks_pane = Pane::new("Tracks")
.meta(&track_meta)
.focused(tracks_focused);
let tracks_block = tracks_pane.build_block();
let tracks_inner = tracks_block.inner(chunks[2]);
frame.render_widget(tracks_block, chunks[2]);
state.tracks_inner_area = tracks_inner;
render_tracks_list(frame, chunks[4], state);
render_tracks_list(frame, tracks_inner, state);
}
fn render_artist_header(frame: &mut Frame, area: Rect, artist: &Artist) {
pub fn render_artist_header(frame: &mut Frame, area: Rect, artist: &Artist) {
let have: u16 = artist.albums.iter().map(|a| a.have).sum();
let total: u16 = artist.albums.iter().map(|a| a.total).sum();
@@ -287,7 +268,7 @@ fn render_artist_header(frame: &mut Frame, area: Rect, artist: &Artist) {
frame.render_widget(paragraph, area);
}
fn render_albums_list(
pub fn render_albums_list(
frame: &mut Frame,
area: Rect,
albums: &[Album],
@@ -348,7 +329,7 @@ fn render_albums_list(
frame.render_stateful_widget(list, area, album_state);
}
fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
pub fn render_tracks_list(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
let focused = state.focus == LibraryFocus::Tracks;
if state.tracks.is_empty() {
+6 -5
View File
@@ -1,9 +1,10 @@
pub mod app_renderer;
pub mod library;
pub mod notifications;
pub mod topbar;
pub mod progress_bar;
pub mod pane;
pub mod statusbar;
pub mod modals;
pub mod notifications;
pub mod pane;
pub mod progress_bar;
pub mod statusbar;
pub mod topbar;
pub mod views;
pub mod which_key_popup;
+3 -1
View File
@@ -10,7 +10,9 @@ use ratatui::{
widgets::{Block, Borders, Clear, Paragraph},
};
use crate::application::notification_state::{format_elapsed, Notification, NotificationManager, MAX_VISIBLE};
use crate::application::notification_state::{
MAX_VISIBLE, Notification, NotificationManager, format_elapsed,
};
use crate::theme;
impl NotificationManager {
+33
View File
@@ -0,0 +1,33 @@
use evil_keys::WhichKeyEntry;
use ratatui::{Frame, layout::Rect, style::{Modifier, Style}};
use which_key::{KeyHint, Position, WhichKey};
use crate::theme;
pub fn render_which_key_popup(
frame: &mut Frame,
area: Rect,
entries: &[WhichKeyEntry],
pending_display: &str,
) {
let hints: Vec<KeyHint> = entries
.iter()
.map(|e| {
let h = KeyHint::new(&e.key, &e.description);
if e.is_group { h.group() } else { h }
})
.collect();
let popup = WhichKey::new(hints)
.title(pending_display)
.position(Position::BottomLeft)
.key_style(Style::default().fg(theme::YELLOW).add_modifier(Modifier::BOLD))
.separator_style(Style::default().fg(theme::GRAY))
.desc_style(Style::default().fg(theme::FG2))
.group_style(Style::default().fg(theme::AQUA))
.border_style(Style::default().fg(theme::BG3))
.bg(theme::BG0);
let popup_rect = popup.layout(area);
frame.render_widget(&popup, popup_rect);
}
+91 -7
View File
@@ -16,6 +16,10 @@ pub struct IndexerOptions {
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct MonitorAlbumResponse {
#[prost(message, optional, tag = "1")]
pub album: ::core::option::Option<AlbumDetail>,
#[prost(message, optional, tag = "2")]
pub artist: ::core::option::Option<ArtistSummary>,
#[prost(message, optional, tag = "3")]
pub release: ::core::option::Option<MonitoredRelease>,
}
#[derive(Clone, Copy, PartialEq, ::prost::Message)]
@@ -72,6 +76,8 @@ pub struct AlbumDetail {
pub monitor_state: i32,
#[prost(message, optional, tag = "12")]
pub download: ::core::option::Option<DownloadInfo>,
#[prost(message, optional, tag = "13")]
pub release: ::core::option::Option<AlbumReleaseDetail>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct DownloadInfo {
@@ -90,13 +96,28 @@ pub struct GetAlbumRequest {
pub album_id: ::prost::alloc::string::String,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct GetAlbumResponse {
pub struct AlbumInfo {
#[prost(message, optional, tag = "1")]
pub album: ::core::option::Option<AlbumDetail>,
#[prost(message, repeated, tag = "2")]
pub tracks: ::prost::alloc::vec::Vec<TrackDetail>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct GetAlbumResponse {
#[prost(message, optional, tag = "1")]
pub info: ::core::option::Option<AlbumInfo>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct AnalyzeAlbumReleaseRequest {
#[prost(string, tag = "1")]
pub album_id: ::prost::alloc::string::String,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct AnalyzeAlbumReleaseResponse {
#[prost(message, optional, tag = "1")]
pub info: ::core::option::Option<AlbumInfo>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct TrackDetail {
#[prost(string, tag = "1")]
pub id: ::prost::alloc::string::String,
@@ -116,8 +137,20 @@ pub struct TrackDetail {
pub explicit: bool,
#[prost(message, repeated, tag = "9")]
pub artists: ::prost::alloc::vec::Vec<ArtistCredit>,
#[prost(message, optional, tag = "10")]
pub file: ::core::option::Option<TrackFile>,
#[prost(string, tag = "10")]
pub file_path: ::prost::alloc::string::String,
#[prost(int64, tag = "11")]
pub file_size: i64,
#[prost(string, tag = "12")]
pub format: ::prost::alloc::string::String,
#[prost(int32, tag = "13")]
pub bit_depth: i32,
#[prost(int32, tag = "14")]
pub sample_rate: i32,
#[prost(int32, tag = "15")]
pub channels: i32,
#[prost(int32, tag = "16")]
pub bitrate_kbps: i32,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ArtistCredit {
@@ -127,13 +160,35 @@ pub struct ArtistCredit {
pub name: ::prost::alloc::string::String,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct TrackFile {
pub struct AlbumReleaseDetail {
#[prost(string, tag = "1")]
pub path: ::prost::alloc::string::String,
pub id: ::prost::alloc::string::String,
#[prost(string, tag = "2")]
pub format: ::prost::alloc::string::String,
#[prost(int64, tag = "3")]
pub size: i64,
#[prost(int32, tag = "3")]
pub bit_depth: i32,
#[prost(int32, tag = "4")]
pub sample_rate: i32,
#[prost(int32, tag = "5")]
pub channels: i32,
#[prost(bool, tag = "6")]
pub is_lossless: bool,
#[prost(string, tag = "7")]
pub source: ::prost::alloc::string::String,
#[prost(int64, tag = "8")]
pub total_size: i64,
#[prost(int32, tag = "9")]
pub total_duration_ms: i32,
#[prost(int32, tag = "10")]
pub track_count: i32,
#[prost(bool, tag = "11")]
pub has_cover_art: bool,
#[prost(bool, tag = "12")]
pub has_cue_sheet: bool,
#[prost(bool, tag = "13")]
pub has_rip_log: bool,
#[prost(string, tag = "14")]
pub path: ::prost::alloc::string::String,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct MonitoredRelease {
@@ -413,5 +468,34 @@ pub mod music_agregator_service_client {
);
self.inner.unary(req, path, codec).await
}
pub async fn analyze_album_release(
&mut self,
request: impl tonic::IntoRequest<super::AnalyzeAlbumReleaseRequest>,
) -> std::result::Result<
tonic::Response<super::AnalyzeAlbumReleaseResponse>,
tonic::Status,
> {
self.inner
.ready()
.await
.map_err(|e| {
tonic::Status::unknown(
format!("Service was not ready: {}", e.into()),
)
})?;
let codec = tonic::codec::ProstCodec::default();
let path = http::uri::PathAndQuery::from_static(
"/music_agregator.v1.MusicAgregatorService/AnalyzeAlbumRelease",
);
let mut req = request.into_request();
req.extensions_mut()
.insert(
GrpcMethod::new(
"music_agregator.v1.MusicAgregatorService",
"AnalyzeAlbumRelease",
),
);
self.inner.unary(req, path, codec).await
}
}
}
+4 -1
View File
@@ -1,2 +1,5 @@
pub use crate::application::library_state::{LibraryFocus, LibraryState};
pub use crate::presentation::library::render_library;
pub use crate::presentation::library::{
render_albums_list, render_artist_header, render_artists_pane, render_detail_pane,
render_library, render_tracks_list,
};
@@ -0,0 +1,14 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"● Complete Album [Album] 2020 ▰▰▰▰▰▰▰▰▰▰ 10/10 FLAC "
"○ Wanted Album [Album] 2021 ▱▱▱▱▱▱▱▱▱▱ 0/8 — "
"◌ Unmonitored Single [Single] 2022 ▱▱▱▱▱▱▱▱▱▱ 0/1 — "
" "
" "
" "
" "
" "
" "
" "
@@ -0,0 +1,14 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"● OK Computer [Album] 1997 ▰▰▰▰▰▰▰▰▰▰ 12/12 FLAC "
"◐ Kid A [Album] 2000 ▰▰▰▰▰▱▱▱▱▱ 5/10 FLAC "
" "
" "
" "
" "
" "
" "
" "
" "
@@ -0,0 +1,14 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"● OK Computer [Album] 1997 ▰▰▰▰▰▰▰▰▰▰ 12/12 FLAC "
"◐ Kid A [Album] 2000 ▰▰▰▰▰▱▱▱▱▱ 5/10 FLAC "
" "
" "
" "
" "
" "
" "
" "
" "
@@ -0,0 +1,10 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"Radiohead "
" "
"status ● Monitored path /music/Radiohead "
"quality FLAC size 2.5 GB "
"albums 2 tracks 17 / 22 "
" "
@@ -0,0 +1,10 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"Tool "
" "
"status ◌ Unmonitored path /music/Tool "
"quality FLAC size 3.8 GB "
"albums 0 tracks 0 / 0 "
" "
@@ -0,0 +1,24 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"┌─[ Artists · 0 ]──────────────┐"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────┘"
@@ -0,0 +1,24 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"┌─[ Artists · 2 ]──────────────┐"
"│◐ Radiohead 17/22 │"
"│! Pink Floyd 0/10 │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────┘"
@@ -0,0 +1,24 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"┌─[ Artists · 2 ]──────────────┐"
"│◐ Radiohead 17/22 │"
"│! Pink Floyd 0/10 │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────┘"
@@ -0,0 +1,28 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"No artist selected "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
" "
@@ -0,0 +1,28 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"Radiohead "
" "
"status ● Monitored path /music/Radiohead "
"quality FLAC size 2.5 GB "
"albums 2 tracks 17 / 22 "
" "
"┌─[ Albums · 2 releases ]──────────────────────────────────────────┐"
"│● OK Computer [Album] 1997 ▰▰▰▰▰▰▰▰▰▰ 12/12 FLAC │"
"│◐ Kid A [Album] 2000 ▰▰▰▰▰▱▱▱▱▱ 5/10 FLAC │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────┘"
"┌─[ Tracks · OK Computer · 12/12 ]─────────────────────────────────┐"
"│(no album selected) │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────┘"
@@ -0,0 +1,28 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"Radiohead "
" "
"status ● Monitored path /music/Radiohead "
"quality FLAC size 2.5 GB "
"albums 2 tracks 17 / 22 "
" "
"┌─[ Albums · 2 releases ]──────────────────────────────────────────┐"
"│● OK Computer [Album] 1997 ▰▰▰▰▰▰▰▰▰▰ 12/12 FLAC │"
"│◐ Kid A [Album] 2000 ▰▰▰▰▰▱▱▱▱▱ 5/10 FLAC │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────┘"
"┌─[ Tracks · OK Computer · 12/12 ]─────────────────────────────────┐"
"│✓ 01 Airbag 4:44 FLAC │"
"│✓ 02 Paranoid Android 6:23 FLAC │"
"│✗ 03 Subterranean Homesick Alien 4:27 — │"
"│ │"
"│ │"
"│ │"
"└──────────────────────────────────────────────────────────────────┘"
@@ -0,0 +1,34 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"┌─[ Artists · 0 ]──────────────┐No artist selected "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"│ │ "
"└──────────────────────────────┘ "
@@ -2,14 +2,13 @@
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"┌─[ Artists · 2 ]──────────────┐┌─[ Detail · UK · Alternative ]────────────────────────────────────┐"
"│◐ Radiohead 17/22 ││Radiohead "
"│! Pink Floyd 0/10 │ "
"│ ││status ● Monitored path /music/Radiohead "
"│ ││quality FLAC size 2.5 GB "
"│ ││albums 2 tracks 17 / 22 "
"│ ││ │"
"│ ││─ albums ─ 2 releases │"
"┌─[ Artists · 2 ]──────────────┐Radiohead "
"│◐ Radiohead 17/22 │ "
"│! Pink Floyd 0/10 │status ● Monitored path /music/Radiohead "
"│ │quality FLAC size 2.5 GB "
"│ │albums 2 tracks 17 / 22 "
"│ │ "
"│ │┌─[ Albums · 2 releases ]──────────────────────────────────────────┐"
"│ ││● OK Computer [Album] 1997 ▰▰▰▰▰▰▰▰▰▰ 12/12 FLAC │"
"│ ││◐ Kid A [Album] 2000 ▰▰▰▰▰▱▱▱▱▱ 5/10 FLAC │"
"│ ││ │"
@@ -20,8 +19,8 @@ expression: terminal.backend()
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││─ tracks · OK Computer 12/12 "
"│ │└──────────────────────────────────────────────────────────────────┘"
"│ │┌─[ Tracks · OK Computer · 12/12 ]─────────────────────────────────┐"
"│ ││(no album selected) │"
"│ ││ │"
"│ ││ │"
@@ -31,4 +30,5 @@ expression: terminal.backend()
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"└──────────────────────────────┘└──────────────────────────────────────────────────────────────────┘"
@@ -1,34 +0,0 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"┌─[ Artists · 0 ]──────────────┐┌─[ Detail · ]────────────────────────────────────────────────────┐"
"│ ││No artist selected │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"└──────────────────────────────┘└──────────────────────────────────────────────────────────────────┘"
@@ -1,34 +0,0 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"┌─[ Artists · 2 ]──────────────┐┌─[ Detail · UK · Alternative ]────────────────────────────────────┐"
"│◐ Radiohead 17/22 ││Radiohead │"
"│! Pink Floyd 0/10 ││ │"
"│ ││status ● Monitored path /music/Radiohead │"
"│ ││quality FLAC size 2.5 GB │"
"│ ││albums 2 tracks 17 / 22 │"
"│ ││ │"
"│ ││─ albums ─ 2 releases │"
"│ ││● OK Computer [Album] 1997 ▰▰▰▰▰▰▰▰▰▰ 12/12 FLAC │"
"│ ││◐ Kid A [Album] 2000 ▰▰▰▰▰▱▱▱▱▱ 5/10 FLAC │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││─ tracks · OK Computer ─ 12/12 │"
"│ ││(no album selected) │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"│ ││ │"
"└──────────────────────────────┘└──────────────────────────────────────────────────────────────────┘"
@@ -0,0 +1,12 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"(no album selected) "
" "
" "
" "
" "
" "
" "
" "
@@ -0,0 +1,12 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"✓ 01 Airbag 4:44 FLAC "
"✓ 02 Paranoid Android 6:23 FLAC "
"✗ 03 Subterranean Homesick Alien 4:27 — "
" "
" "
" "
" "
" "
@@ -0,0 +1,12 @@
---
source: tests/ui_snapshots.rs
expression: terminal.backend()
---
"✓ 01 Airbag 4:44 FLAC "
"✓ 02 Paranoid Android 6:23 FLAC "
"✗ 03 Subterranean Homesick Alien 4:27 — "
" "
" "
" "
" "
" "
+290 -44
View File
@@ -1,7 +1,10 @@
use ratatui::{Terminal, backend::TestBackend, widgets::Paragraph};
use ui_agregator::app::Tab;
use ui_agregator::data::{Album, AlbumStatus, Artist, MonitorState};
use ui_agregator::ui::library::{LibraryFocus, LibraryState, render_library};
use ui_agregator::data::{Album, AlbumStatus, Artist, MonitorState, Track};
use ui_agregator::ui::library::{
LibraryFocus, LibraryState, render_albums_list, render_artist_header, render_artists_pane,
render_detail_pane, render_library, render_tracks_list,
};
use ui_agregator::ui::modals::render_help_modal;
use ui_agregator::ui::progress_bar::progress_bar;
use ui_agregator::ui::topbar::render_topbar;
@@ -66,6 +69,38 @@ fn test_artists() -> Vec<Artist> {
]
}
fn test_tracks() -> Vec<Track> {
vec![
Track {
id: "t1".to_string(),
number: 1,
disc: 1,
title: "Airbag".to_string(),
duration: "4:44".to_string(),
have: true,
quality: "FLAC".to_string(),
},
Track {
id: "t2".to_string(),
number: 2,
disc: 1,
title: "Paranoid Android".to_string(),
duration: "6:23".to_string(),
have: true,
quality: "FLAC".to_string(),
},
Track {
id: "t3".to_string(),
number: 3,
disc: 1,
title: "Subterranean Homesick Alien".to_string(),
duration: "4:27".to_string(),
have: false,
quality: "".to_string(),
},
]
}
mod progress_bar_snapshots {
use super::*;
@@ -143,47 +178,6 @@ mod topbar_snapshots {
}
}
mod library_snapshots {
use super::*;
#[test]
fn empty() {
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
let mut state = LibraryState::new(vec![]);
terminal
.draw(|f| {
render_library(f, f.area(), &mut state);
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn with_artists() {
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
let mut state = LibraryState::new(test_artists());
terminal
.draw(|f| {
render_library(f, f.area(), &mut state);
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn albums_focused() {
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
let mut state = LibraryState::new(test_artists());
state.focus = LibraryFocus::Albums;
terminal
.draw(|f| {
render_library(f, f.area(), &mut state);
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
}
mod help_modal_snapshots {
use super::*;
@@ -210,7 +204,259 @@ mod help_modal_snapshots {
}
}
mod library_state {
mod library_page_snapshots {
use super::*;
#[test]
fn full_page_empty() {
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
let mut state = LibraryState::new(vec![]);
terminal
.draw(|f| render_library(f, f.area(), &mut state))
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn full_page_with_data() {
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
let mut state = LibraryState::new(test_artists());
terminal
.draw(|f| render_library(f, f.area(), &mut state))
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
}
mod artists_pane_snapshots {
use super::*;
#[test]
fn empty() {
let mut terminal = Terminal::new(TestBackend::new(32, 20)).unwrap();
let mut state = LibraryState::new(vec![]);
terminal
.draw(|f| render_artists_pane(f, f.area(), &mut state))
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn with_artists_focused() {
let mut terminal = Terminal::new(TestBackend::new(32, 20)).unwrap();
let mut state = LibraryState::new(test_artists());
state.focus = LibraryFocus::Artists;
terminal
.draw(|f| render_artists_pane(f, f.area(), &mut state))
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn with_artists_unfocused() {
let mut terminal = Terminal::new(TestBackend::new(32, 20)).unwrap();
let mut state = LibraryState::new(test_artists());
state.focus = LibraryFocus::Albums;
terminal
.draw(|f| render_artists_pane(f, f.area(), &mut state))
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
}
mod artist_header_snapshots {
use super::*;
#[test]
fn monitored_artist() {
let mut terminal = Terminal::new(TestBackend::new(68, 6)).unwrap();
let artists = test_artists();
terminal
.draw(|f| render_artist_header(f, f.area(), &artists[0]))
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn unmonitored_artist() {
let mut terminal = Terminal::new(TestBackend::new(68, 6)).unwrap();
let artist = Artist {
id: "3".to_string(),
name: "Tool".to_string(),
country: "US".to_string(),
genres: vec!["Progressive Metal".to_string()],
monitor_state: MonitorState::Unmonitored,
path: "/music/Tool".to_string(),
quality: "FLAC".to_string(),
size_gb: 3.8,
albums: vec![],
};
terminal
.draw(|f| render_artist_header(f, f.area(), &artist))
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
}
mod albums_list_snapshots {
use super::*;
use ratatui::widgets::ListState;
#[test]
fn with_albums_focused() {
let mut terminal = Terminal::new(TestBackend::new(68, 10)).unwrap();
let artists = test_artists();
let albums = &artists[0].albums;
let mut album_state = ListState::default();
album_state.select(Some(0));
terminal
.draw(|f| {
render_albums_list(f, f.area(), albums, LibraryFocus::Albums, &mut album_state);
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn with_albums_unfocused() {
let mut terminal = Terminal::new(TestBackend::new(68, 10)).unwrap();
let artists = test_artists();
let albums = &artists[0].albums;
let mut album_state = ListState::default();
album_state.select(Some(0));
terminal
.draw(|f| {
render_albums_list(f, f.area(), albums, LibraryFocus::Artists, &mut album_state);
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn mixed_statuses() {
let mut terminal = Terminal::new(TestBackend::new(68, 10)).unwrap();
let albums = vec![
Album {
id: "1".to_string(),
title: "Complete Album".to_string(),
year: 2020,
album_type: "Album".to_string(),
monitored: true,
total: 10,
have: 10,
quality: "FLAC".to_string(),
status: AlbumStatus::Complete,
},
Album {
id: "2".to_string(),
title: "Wanted Album".to_string(),
year: 2021,
album_type: "Album".to_string(),
monitored: true,
total: 8,
have: 0,
quality: "".to_string(),
status: AlbumStatus::Wanted,
},
Album {
id: "3".to_string(),
title: "Unmonitored Single".to_string(),
year: 2022,
album_type: "Single".to_string(),
monitored: false,
total: 1,
have: 0,
quality: "".to_string(),
status: AlbumStatus::Unmonitored,
},
];
let mut album_state = ListState::default();
album_state.select(Some(1));
terminal
.draw(|f| {
render_albums_list(f, f.area(), &albums, LibraryFocus::Albums, &mut album_state);
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
}
mod tracks_list_snapshots {
use super::*;
#[test]
fn no_tracks() {
let mut terminal = Terminal::new(TestBackend::new(68, 8)).unwrap();
let mut state = LibraryState::new(test_artists());
terminal
.draw(|f| render_tracks_list(f, f.area(), &mut state))
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn with_tracks_focused() {
let mut terminal = Terminal::new(TestBackend::new(68, 8)).unwrap();
let mut state = LibraryState::new(test_artists());
state.tracks = test_tracks();
state.focus = LibraryFocus::Tracks;
terminal
.draw(|f| render_tracks_list(f, f.area(), &mut state))
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn with_tracks_unfocused() {
let mut terminal = Terminal::new(TestBackend::new(68, 8)).unwrap();
let mut state = LibraryState::new(test_artists());
state.tracks = test_tracks();
state.focus = LibraryFocus::Artists;
terminal
.draw(|f| render_tracks_list(f, f.area(), &mut state))
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
}
mod detail_pane_snapshots {
use super::*;
#[test]
fn no_artist_selected() {
let mut terminal = Terminal::new(TestBackend::new(68, 24)).unwrap();
let mut state = LibraryState::new(vec![]);
terminal
.draw(|f| render_detail_pane(f, f.area(), &mut state))
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn with_artist_albums_focused() {
let mut terminal = Terminal::new(TestBackend::new(68, 24)).unwrap();
let mut state = LibraryState::new(test_artists());
state.focus = LibraryFocus::Albums;
terminal
.draw(|f| render_detail_pane(f, f.area(), &mut state))
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn with_artist_tracks_focused() {
let mut terminal = Terminal::new(TestBackend::new(68, 24)).unwrap();
let mut state = LibraryState::new(test_artists());
state.tracks = test_tracks();
state.focus = LibraryFocus::Tracks;
terminal
.draw(|f| render_detail_pane(f, f.area(), &mut state))
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
}
mod library_state_tests {
use super::*;
#[test]