Compare commits
13 Commits
c1205e5fb0
...
d1616c63bc
| Author | SHA1 | Date | |
|---|---|---|---|
| d1616c63bc | |||
| cde3fe1979 | |||
| edf8d5b160 | |||
| 498e92f2e4 | |||
| eb114fc614 | |||
| 1c1dadf5cd | |||
| f859c40eb1 | |||
| bee1f82405 | |||
| 216a11b9db | |||
| 5a34fafd3f | |||
| 85093d0ff0 | |||
| 1232b76fff | |||
| 7a35958c42 |
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -151,6 +151,21 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
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]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
@@ -329,6 +344,15 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "evil-keys"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"crossterm",
|
||||||
|
"indexmap 2.14.0",
|
||||||
|
"proptest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "eyre"
|
name = "eyre"
|
||||||
version = "0.6.12"
|
version = "0.6.12"
|
||||||
@@ -413,6 +437,18 @@ dependencies = [
|
|||||||
"wasi",
|
"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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -421,7 +457,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi",
|
"r-efi 6.0.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
@@ -780,6 +816,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.19"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "object"
|
name = "object"
|
||||||
version = "0.37.3"
|
version = "0.37.3"
|
||||||
@@ -900,6 +945,25 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "prost"
|
name = "prost"
|
||||||
version = "0.13.5"
|
version = "0.13.5"
|
||||||
@@ -952,6 +1016,12 @@ dependencies = [
|
|||||||
"prost",
|
"prost",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-error"
|
||||||
|
version = "1.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -961,6 +1031,12 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "6.0.0"
|
version = "6.0.0"
|
||||||
@@ -974,8 +1050,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rand_chacha",
|
"rand_chacha 0.3.1",
|
||||||
"rand_core",
|
"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]]
|
[[package]]
|
||||||
@@ -985,7 +1071,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ppv-lite86",
|
"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]]
|
[[package]]
|
||||||
@@ -997,6 +1093,24 @@ dependencies = [
|
|||||||
"getrandom 0.2.17",
|
"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]]
|
[[package]]
|
||||||
name = "ratatui"
|
name = "ratatui"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@@ -1094,6 +1208,18 @@ version = "1.0.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
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]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -1424,7 +1550,7 @@ dependencies = [
|
|||||||
"indexmap 1.9.3",
|
"indexmap 1.9.3",
|
||||||
"pin-project",
|
"pin-project",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rand",
|
"rand 0.8.6",
|
||||||
"slab",
|
"slab",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
@@ -1524,6 +1650,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"color-eyre",
|
"color-eyre",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
|
"evil-keys",
|
||||||
"insta",
|
"insta",
|
||||||
"nix",
|
"nix",
|
||||||
"prost",
|
"prost",
|
||||||
@@ -1533,8 +1660,15 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tonic",
|
"tonic",
|
||||||
"tonic-build",
|
"tonic-build",
|
||||||
|
"which-key",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unarray"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
@@ -1588,6 +1722,15 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
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]]
|
[[package]]
|
||||||
name = "want"
|
name = "want"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1655,6 +1798,16 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "which-key"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"insta",
|
||||||
|
"proptest",
|
||||||
|
"ratatui",
|
||||||
|
"unicode-width 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["crates/evil-keys", "crates/which-key"]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ui-agregator"
|
name = "ui-agregator"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -10,6 +13,8 @@ color-eyre = "0.6"
|
|||||||
nix = { version = "0.29", features = ["fs"] }
|
nix = { version = "0.29", features = ["fs"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
|
evil-keys = { path = "crates/evil-keys" }
|
||||||
|
which-key = { path = "crates/which-key" }
|
||||||
|
|
||||||
tonic = "0.12"
|
tonic = "0.12"
|
||||||
prost = "0.13"
|
prost = "0.13"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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(), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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), " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -2,16 +2,101 @@
|
|||||||
|
|
||||||
use crossterm::event::MouseButton;
|
use crossterm::event::MouseButton;
|
||||||
use ratatui::widgets::ListState;
|
use ratatui::widgets::ListState;
|
||||||
|
use tokio::sync::mpsc::Sender;
|
||||||
|
|
||||||
use crate::application::app_state::App;
|
use crate::application::app_state::App;
|
||||||
use crate::data::{Artist, Track};
|
use crate::data::{Artist, Track};
|
||||||
use crate::domain::conversions::{convert_artist, convert_track};
|
use crate::domain::conversions::{convert_album, convert_artist, convert_track};
|
||||||
use crate::domain::navigation::Tab;
|
use crate::domain::navigation::{ModalKind, Tab};
|
||||||
use crate::grpc::GrpcResponse;
|
use crate::grpc::{GrpcRequest, GrpcResponse};
|
||||||
|
use crate::input::AppAction;
|
||||||
use crate::ui::library::{LibraryFocus, LibraryState};
|
use crate::ui::library::{LibraryFocus, LibraryState};
|
||||||
use crate::ui::notifications::NotifKind;
|
use crate::ui::notifications::NotifKind;
|
||||||
|
|
||||||
impl App {
|
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) {
|
pub fn handle_escape(&mut self) {
|
||||||
if self.notifications_open {
|
if self.notifications_open {
|
||||||
self.notifications_open = false;
|
self.notifications_open = false;
|
||||||
@@ -111,7 +196,7 @@ impl App {
|
|||||||
|
|
||||||
match self.tab {
|
match self.tab {
|
||||||
Tab::Library => {
|
Tab::Library => {
|
||||||
self.handle_library_click(x, rel_y);
|
self.handle_library_click(x, y);
|
||||||
}
|
}
|
||||||
Tab::Wanted => {
|
Tab::Wanted => {
|
||||||
self.select_list_item(&mut self.wanted_state.clone(), self.wanted.len(), rel_y);
|
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) {
|
fn handle_library_click(&mut self, x: u16, y: u16) {
|
||||||
const ARTISTS_PANE_WIDTH: u16 = 32;
|
let artists = self.library.artists_inner_area;
|
||||||
const BORDER_TOP: usize = 1;
|
let albums = self.library.albums_inner_area;
|
||||||
const HEADER_HEIGHT: usize = 6;
|
let tracks = self.library.tracks_inner_area;
|
||||||
const DIVIDER_HEIGHT: usize = 1;
|
|
||||||
const ALBUMS_START_ROW: usize = BORDER_TOP + HEADER_HEIGHT + DIVIDER_HEIGHT;
|
|
||||||
|
|
||||||
if x < ARTISTS_PANE_WIDTH {
|
if x >= artists.x
|
||||||
if rel_y > 0 && rel_y <= self.library.artists.len() {
|
&& x < artists.x + artists.width
|
||||||
self.library.artist_state.select(Some(rel_y - 1));
|
&& 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.album_state.select(Some(0));
|
||||||
self.library.track_state.select(Some(0));
|
self.library.track_state.select(Some(0));
|
||||||
self.library.focus = LibraryFocus::Artists;
|
self.library.focus = LibraryFocus::Artists;
|
||||||
}
|
}
|
||||||
} else if rel_y >= ALBUMS_START_ROW {
|
} else if x >= albums.x
|
||||||
let album_row = rel_y - ALBUMS_START_ROW;
|
&& x < albums.x + albums.width
|
||||||
let content_height = self.main_area.height.saturating_sub(10) as usize;
|
&& y >= albums.y
|
||||||
let albums_section_height = (content_height * 40) / 100;
|
&& y < albums.y + albums.height
|
||||||
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()
|
|
||||||
{
|
{
|
||||||
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.track_state.select(Some(0));
|
||||||
self.library.focus = LibraryFocus::Albums;
|
self.library.focus = LibraryFocus::Albums;
|
||||||
}
|
}
|
||||||
} else {
|
} else if x >= tracks.x
|
||||||
let track_row = rel_y - tracks_start_row;
|
&& x < tracks.x + tracks.width
|
||||||
if let Some(album) = self.library.selected_album()
|
&& y >= tracks.y
|
||||||
&& track_row < album.total as usize
|
&& 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;
|
self.library.focus = LibraryFocus::Tracks;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn select_list_item(&self, _state: &mut ListState, _len: usize, _rel_y: usize) {}
|
fn select_list_item(&self, _state: &mut ListState, _len: usize, _rel_y: usize) {}
|
||||||
|
|
||||||
@@ -288,8 +376,25 @@ impl App {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
GrpcResponse::Album { album, tracks } => {
|
GrpcResponse::Album { album, tracks } => {
|
||||||
let converted: Vec<Track> = tracks.into_iter().map(convert_track).collect();
|
let album_downloaded = album
|
||||||
self.library.cache_tracks(album.id, converted);
|
.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) => {
|
GrpcResponse::Error(msg) => {
|
||||||
self.set_error(msg);
|
self.set_error(msg);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use ratatui::layout::Rect;
|
||||||
use ratatui::widgets::ListState;
|
use ratatui::widgets::ListState;
|
||||||
|
|
||||||
use crate::data::{Album, Artist, Track};
|
use crate::data::{Album, Artist, Track};
|
||||||
@@ -21,6 +22,9 @@ pub struct LibraryState {
|
|||||||
pub artist_state: ListState,
|
pub artist_state: ListState,
|
||||||
pub album_state: ListState,
|
pub album_state: ListState,
|
||||||
pub track_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>>,
|
tracks_cache: HashMap<String, Vec<Track>>,
|
||||||
pending_album_id: Option<String>,
|
pending_album_id: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -46,6 +50,9 @@ impl LibraryState {
|
|||||||
artist_state,
|
artist_state,
|
||||||
album_state,
|
album_state,
|
||||||
track_state,
|
track_state,
|
||||||
|
artists_inner_area: Rect::default(),
|
||||||
|
albums_inner_area: Rect::default(),
|
||||||
|
tracks_inner_area: Rect::default(),
|
||||||
tracks_cache: HashMap::new(),
|
tracks_cache: HashMap::new(),
|
||||||
pending_album_id: None,
|
pending_album_id: None,
|
||||||
}
|
}
|
||||||
@@ -131,8 +138,9 @@ impl LibraryState {
|
|||||||
pub fn focus_left(&mut self) {
|
pub fn focus_left(&mut self) {
|
||||||
match self.focus {
|
match self.focus {
|
||||||
LibraryFocus::Artists => {}
|
LibraryFocus::Artists => {}
|
||||||
LibraryFocus::Albums => self.focus = LibraryFocus::Artists,
|
LibraryFocus::Albums | LibraryFocus::Tracks => {
|
||||||
LibraryFocus::Tracks => self.focus = LibraryFocus::Albums,
|
self.focus = LibraryFocus::Artists;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,21 +151,20 @@ impl LibraryState {
|
|||||||
self.focus = LibraryFocus::Albums;
|
self.focus = LibraryFocus::Albums;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LibraryFocus::Albums => {
|
LibraryFocus::Albums | LibraryFocus::Tracks => {}
|
||||||
if self.selected_album().is_some() {
|
|
||||||
self.focus = LibraryFocus::Tracks;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LibraryFocus::Tracks => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cycle_focus(&mut self) {
|
pub fn focus_down(&mut self) {
|
||||||
self.focus = match self.focus {
|
if self.focus == LibraryFocus::Albums {
|
||||||
LibraryFocus::Artists => LibraryFocus::Albums,
|
self.focus = LibraryFocus::Tracks;
|
||||||
LibraryFocus::Albums => LibraryFocus::Tracks,
|
}
|
||||||
LibraryFocus::Tracks => LibraryFocus::Artists,
|
}
|
||||||
};
|
|
||||||
|
pub fn focus_up(&mut self) {
|
||||||
|
if self.focus == LibraryFocus::Tracks {
|
||||||
|
self.focus = LibraryFocus::Albums;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reset_album_selection(&mut self) {
|
fn reset_album_selection(&mut self) {
|
||||||
@@ -242,6 +249,19 @@ impl LibraryState {
|
|||||||
self.selected_album().map(|a| a.id.clone())
|
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) {
|
pub fn clear_cache(&mut self) {
|
||||||
self.tracks_cache.clear();
|
self.tracks_cache.clear();
|
||||||
self.pending_album_id = None;
|
self.pending_album_id = None;
|
||||||
|
|||||||
+39
-21
@@ -21,27 +21,32 @@ pub fn convert_album(detail: AlbumDetail) -> Album {
|
|||||||
let monitor_state = MonitorState::from_proto(detail.monitor_state);
|
let monitor_state = MonitorState::from_proto(detail.monitor_state);
|
||||||
let monitored = monitor_state.is_monitored();
|
let monitored = monitor_state.is_monitored();
|
||||||
|
|
||||||
let (have, status, quality) = if let Some(download) = detail.download {
|
let (have, status, quality) = if let Some(download) = &detail.download {
|
||||||
let have = if download.state == "downloaded" {
|
let is_completed = matches!(download.state.as_str(), "completed" | "downloaded");
|
||||||
detail.total_tracks as u16
|
let have = if is_completed {
|
||||||
|
detail
|
||||||
|
.release
|
||||||
|
.as_ref()
|
||||||
|
.map(|r| r.track_count as u16)
|
||||||
|
.unwrap_or(detail.total_tracks as u16)
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
let status = match download.state.as_str() {
|
let status = if is_completed {
|
||||||
"downloaded" => AlbumStatus::Complete,
|
AlbumStatus::Complete
|
||||||
"downloading" => AlbumStatus::Partial,
|
} else if download.state == "downloading" {
|
||||||
_ => {
|
AlbumStatus::Partial
|
||||||
if monitored {
|
} else if monitored {
|
||||||
AlbumStatus::Wanted
|
AlbumStatus::Wanted
|
||||||
} else {
|
} else {
|
||||||
AlbumStatus::Unmonitored
|
AlbumStatus::Unmonitored
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let quality = if download.quality.is_empty() {
|
let quality = if !download.format.is_empty() {
|
||||||
"—".to_string()
|
download.format.clone()
|
||||||
|
} else if !download.quality.is_empty() {
|
||||||
|
download.quality.clone()
|
||||||
} else {
|
} else {
|
||||||
download.quality
|
"—".to_string()
|
||||||
};
|
};
|
||||||
(have, status, quality)
|
(have, status, quality)
|
||||||
} else {
|
} else {
|
||||||
@@ -53,13 +58,23 @@ pub fn convert_album(detail: AlbumDetail) -> Album {
|
|||||||
(0, status, "—".to_string())
|
(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 {
|
Album {
|
||||||
id: detail.id,
|
id: detail.id,
|
||||||
title: detail.title,
|
title: detail.title,
|
||||||
year,
|
year,
|
||||||
album_type: detail.album_type,
|
album_type: detail.album_type,
|
||||||
monitored,
|
monitored,
|
||||||
total: detail.total_tracks as u16,
|
total,
|
||||||
have,
|
have,
|
||||||
quality,
|
quality,
|
||||||
status,
|
status,
|
||||||
@@ -74,13 +89,16 @@ pub fn parse_year(date_str: &str) -> u16 {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn convert_track(detail: TrackDetail) -> Track {
|
pub fn convert_track(detail: TrackDetail, album_downloaded: bool, album_quality: &str) -> Track {
|
||||||
let have = detail.file.is_some();
|
let has_file = !detail.file_path.is_empty();
|
||||||
let quality = detail
|
let have = has_file || album_downloaded;
|
||||||
.file
|
let quality = if !detail.format.is_empty() {
|
||||||
.as_ref()
|
detail.format
|
||||||
.map(|f| f.format.clone())
|
} else if album_downloaded {
|
||||||
.unwrap_or_else(|| "—".to_string());
|
album_quality.to_string()
|
||||||
|
} else {
|
||||||
|
"—".to_string()
|
||||||
|
};
|
||||||
let duration = format_duration(detail.duration_ms);
|
let duration = format_duration(detail.duration_ms);
|
||||||
|
|
||||||
Track {
|
Track {
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
|
pub mod aggregates;
|
||||||
|
pub mod conversions;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod navigation;
|
pub mod navigation;
|
||||||
pub mod conversions;
|
|
||||||
pub mod aggregates;
|
|
||||||
|
|||||||
@@ -51,10 +51,13 @@ impl GrpcClient {
|
|||||||
) -> Result<(AlbumDetail, Vec<TrackDetail>), tonic::Status> {
|
) -> Result<(AlbumDetail, Vec<TrackDetail>), tonic::Status> {
|
||||||
let response = self.music.get_album(GetAlbumRequest { album_id }).await?;
|
let response = self.music.get_album(GetAlbumRequest { album_id }).await?;
|
||||||
let inner = response.into_inner();
|
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
|
.album
|
||||||
.ok_or_else(|| tonic::Status::not_found("Album not found in response"))?;
|
.ok_or_else(|| tonic::Status::not_found("Album not found in response"))?;
|
||||||
Ok((album, inner.tracks))
|
Ok((album, info.tracks))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod action;
|
||||||
|
pub mod keymap;
|
||||||
|
|
||||||
|
pub use action::AppAction;
|
||||||
|
pub use keymap::{build_insert_keymap, build_normal_keymap};
|
||||||
@@ -5,6 +5,7 @@ pub mod data;
|
|||||||
pub mod domain;
|
pub mod domain;
|
||||||
pub mod grpc;
|
pub mod grpc;
|
||||||
pub mod infrastructure;
|
pub mod infrastructure;
|
||||||
|
pub mod input;
|
||||||
pub mod presentation;
|
pub mod presentation;
|
||||||
pub mod proto;
|
pub mod proto;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
|||||||
+35
-10
@@ -11,11 +11,14 @@ use crossterm::{
|
|||||||
},
|
},
|
||||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||||
};
|
};
|
||||||
|
use evil_keys::{DispatchResult, Dispatcher};
|
||||||
use ratatui::prelude::*;
|
use ratatui::prelude::*;
|
||||||
|
|
||||||
use ui_agregator::app::App;
|
use ui_agregator::app::App;
|
||||||
use ui_agregator::config::Config;
|
use ui_agregator::config::Config;
|
||||||
use ui_agregator::grpc::{GrpcRequest, spawn_grpc_worker};
|
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);
|
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 terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
|
||||||
let mut app = App::new();
|
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());
|
let (grpc_tx, mut grpc_rx) = spawn_grpc_worker(config.grpc_addr());
|
||||||
|
|
||||||
if grpc_tx.try_send(GrpcRequest::GetArtists).is_err() {
|
if grpc_tx.try_send(GrpcRequest::GetArtists).is_err() {
|
||||||
@@ -66,7 +79,14 @@ async fn run() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
while app.running {
|
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() {
|
if let Ok(response) = grpc_rx.try_recv() {
|
||||||
app.handle_grpc_response(response);
|
app.handle_grpc_response(response);
|
||||||
@@ -78,18 +98,23 @@ async fn run() -> Result<()> {
|
|||||||
|
|
||||||
if event::poll(TICK_RATE)? {
|
if event::poll(TICK_RATE)? {
|
||||||
match event::read()? {
|
match event::read()? {
|
||||||
Event::Key(key) if key.kind == KeyEventKind::Press => {
|
Event::Key(key) => {
|
||||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
if key.kind == KeyEventKind::Press
|
||||||
|
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
&& key.code == KeyCode::Char('c')
|
&& key.code == KeyCode::Char('c')
|
||||||
{
|
{
|
||||||
app.running = false;
|
app.running = false;
|
||||||
} else if key.code == KeyCode::Esc {
|
} else {
|
||||||
app.handle_escape();
|
match dispatcher.dispatch(key) {
|
||||||
} else if key.code == KeyCode::Char('r')
|
DispatchResult::Matched { action, count } => {
|
||||||
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
app.handle_action(action, count, &grpc_tx);
|
||||||
{
|
}
|
||||||
app.library.clear_cache();
|
DispatchResult::Pending
|
||||||
let _ = grpc_tx.try_send(GrpcRequest::GetArtists);
|
| DispatchResult::Cancelled
|
||||||
|
| DispatchResult::CountAccumulated
|
||||||
|
| DispatchResult::Ignored
|
||||||
|
| DispatchResult::NotFound => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::Mouse(mouse) => match mouse.kind {
|
Event::Mouse(mouse) => match mouse.kind {
|
||||||
|
|||||||
+37
-56
@@ -12,7 +12,7 @@ use crate::application::library_state::{LibraryFocus, LibraryState};
|
|||||||
use crate::data::{Album, AlbumStatus, Artist, MonitorState};
|
use crate::data::{Album, AlbumStatus, Artist, MonitorState};
|
||||||
use crate::domain::aggregates::artist_status;
|
use crate::domain::aggregates::artist_status;
|
||||||
use crate::theme;
|
use crate::theme;
|
||||||
use crate::ui::pane::{Pane, section_divider};
|
use crate::ui::pane::Pane;
|
||||||
use crate::ui::progress_bar::progress_bar;
|
use crate::ui::progress_bar::progress_bar;
|
||||||
|
|
||||||
fn status_icon(status: AlbumStatus, monitored: bool) -> (char, Style) {
|
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);
|
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 focused = state.focus == LibraryFocus::Artists;
|
||||||
let artist_count = state.artists.len();
|
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 block = pane.build_block();
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
state.artists_inner_area = inner;
|
||||||
|
|
||||||
let items: Vec<ListItem> = state
|
let items: Vec<ListItem> = state
|
||||||
.artists
|
.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);
|
frame.render_stateful_widget(list, inner, &mut state.artist_state);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
|
pub fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
|
||||||
let focused = state.focus == LibraryFocus::Albums || state.focus == LibraryFocus::Tracks;
|
frame.render_widget(
|
||||||
|
Paragraph::new("").style(Style::default().bg(theme::BG0)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
|
||||||
let artist = state.selected_artist();
|
let Some(artist) = state.selected_artist() else {
|
||||||
|
|
||||||
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 msg = Paragraph::new(Span::styled(
|
let msg = Paragraph::new(Span::styled(
|
||||||
"No artist selected",
|
"No artist selected",
|
||||||
Style::default().fg(theme::GRAY),
|
Style::default().fg(theme::GRAY),
|
||||||
));
|
));
|
||||||
frame.render_widget(msg, inner);
|
frame.render_widget(msg, area);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let chunks = Layout::vertical([
|
let chunks = Layout::vertical([
|
||||||
Constraint::Length(6),
|
Constraint::Length(6),
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Percentage(40),
|
Constraint::Percentage(40),
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Fill(1),
|
Constraint::Fill(1),
|
||||||
])
|
])
|
||||||
.split(inner);
|
.split(area);
|
||||||
|
|
||||||
render_artist_header(frame, chunks[0], artist);
|
render_artist_header(frame, chunks[0], artist);
|
||||||
|
|
||||||
let albums_count = artist.albums.len();
|
let albums_count = artist.albums.len();
|
||||||
let albums_label = format!("{} releases", albums_count);
|
let albums_label = format!("{} releases", albums_count);
|
||||||
let album_divider = section_divider("albums", Some(&albums_label));
|
let albums_focused = state.focus == LibraryFocus::Albums;
|
||||||
frame.render_widget(Paragraph::new(album_divider), chunks[1]);
|
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();
|
let selected_artist_idx = state.artist_state.selected();
|
||||||
if let Some(idx) = selected_artist_idx
|
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 albums = artist.albums.clone();
|
||||||
let focus = state.focus;
|
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
|
let album_title = state
|
||||||
@@ -213,14 +184,24 @@ fn render_detail_pane(frame: &mut Frame, area: Rect, state: &mut LibraryState) {
|
|||||||
.selected_album()
|
.selected_album()
|
||||||
.map(|a| format!("{}/{}", a.have, a.total))
|
.map(|a| format!("{}/{}", a.have, a.total))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let track_label = format!("tracks · {}", album_title);
|
let track_meta = if album_title.is_empty() {
|
||||||
let track_divider = section_divider(&track_label, Some(&track_counts));
|
String::new()
|
||||||
frame.render_widget(Paragraph::new(track_divider), chunks[3]);
|
} 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 have: u16 = artist.albums.iter().map(|a| a.have).sum();
|
||||||
let total: u16 = artist.albums.iter().map(|a| a.total).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);
|
frame.render_widget(paragraph, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_albums_list(
|
pub fn render_albums_list(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
albums: &[Album],
|
albums: &[Album],
|
||||||
@@ -348,7 +329,7 @@ fn render_albums_list(
|
|||||||
frame.render_stateful_widget(list, area, album_state);
|
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;
|
let focused = state.focus == LibraryFocus::Tracks;
|
||||||
|
|
||||||
if state.tracks.is_empty() {
|
if state.tracks.is_empty() {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
pub mod app_renderer;
|
pub mod app_renderer;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
pub mod notifications;
|
|
||||||
pub mod topbar;
|
|
||||||
pub mod progress_bar;
|
|
||||||
pub mod pane;
|
|
||||||
pub mod statusbar;
|
|
||||||
pub mod modals;
|
pub mod modals;
|
||||||
|
pub mod notifications;
|
||||||
|
pub mod pane;
|
||||||
|
pub mod progress_bar;
|
||||||
|
pub mod statusbar;
|
||||||
|
pub mod topbar;
|
||||||
pub mod views;
|
pub mod views;
|
||||||
|
pub mod which_key_popup;
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ use ratatui::{
|
|||||||
widgets::{Block, Borders, Clear, Paragraph},
|
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;
|
use crate::theme;
|
||||||
|
|
||||||
impl NotificationManager {
|
impl NotificationManager {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ pub struct IndexerOptions {
|
|||||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
pub struct MonitorAlbumResponse {
|
pub struct MonitorAlbumResponse {
|
||||||
#[prost(message, optional, tag = "1")]
|
#[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>,
|
pub release: ::core::option::Option<MonitoredRelease>,
|
||||||
}
|
}
|
||||||
#[derive(Clone, Copy, PartialEq, ::prost::Message)]
|
#[derive(Clone, Copy, PartialEq, ::prost::Message)]
|
||||||
@@ -72,6 +76,8 @@ pub struct AlbumDetail {
|
|||||||
pub monitor_state: i32,
|
pub monitor_state: i32,
|
||||||
#[prost(message, optional, tag = "12")]
|
#[prost(message, optional, tag = "12")]
|
||||||
pub download: ::core::option::Option<DownloadInfo>,
|
pub download: ::core::option::Option<DownloadInfo>,
|
||||||
|
#[prost(message, optional, tag = "13")]
|
||||||
|
pub release: ::core::option::Option<AlbumReleaseDetail>,
|
||||||
}
|
}
|
||||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
pub struct DownloadInfo {
|
pub struct DownloadInfo {
|
||||||
@@ -90,13 +96,28 @@ pub struct GetAlbumRequest {
|
|||||||
pub album_id: ::prost::alloc::string::String,
|
pub album_id: ::prost::alloc::string::String,
|
||||||
}
|
}
|
||||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
pub struct GetAlbumResponse {
|
pub struct AlbumInfo {
|
||||||
#[prost(message, optional, tag = "1")]
|
#[prost(message, optional, tag = "1")]
|
||||||
pub album: ::core::option::Option<AlbumDetail>,
|
pub album: ::core::option::Option<AlbumDetail>,
|
||||||
#[prost(message, repeated, tag = "2")]
|
#[prost(message, repeated, tag = "2")]
|
||||||
pub tracks: ::prost::alloc::vec::Vec<TrackDetail>,
|
pub tracks: ::prost::alloc::vec::Vec<TrackDetail>,
|
||||||
}
|
}
|
||||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
#[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 {
|
pub struct TrackDetail {
|
||||||
#[prost(string, tag = "1")]
|
#[prost(string, tag = "1")]
|
||||||
pub id: ::prost::alloc::string::String,
|
pub id: ::prost::alloc::string::String,
|
||||||
@@ -116,8 +137,20 @@ pub struct TrackDetail {
|
|||||||
pub explicit: bool,
|
pub explicit: bool,
|
||||||
#[prost(message, repeated, tag = "9")]
|
#[prost(message, repeated, tag = "9")]
|
||||||
pub artists: ::prost::alloc::vec::Vec<ArtistCredit>,
|
pub artists: ::prost::alloc::vec::Vec<ArtistCredit>,
|
||||||
#[prost(message, optional, tag = "10")]
|
#[prost(string, tag = "10")]
|
||||||
pub file: ::core::option::Option<TrackFile>,
|
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)]
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
pub struct ArtistCredit {
|
pub struct ArtistCredit {
|
||||||
@@ -127,13 +160,35 @@ pub struct ArtistCredit {
|
|||||||
pub name: ::prost::alloc::string::String,
|
pub name: ::prost::alloc::string::String,
|
||||||
}
|
}
|
||||||
#[derive(Clone, PartialEq, ::prost::Message)]
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
pub struct TrackFile {
|
pub struct AlbumReleaseDetail {
|
||||||
#[prost(string, tag = "1")]
|
#[prost(string, tag = "1")]
|
||||||
pub path: ::prost::alloc::string::String,
|
pub id: ::prost::alloc::string::String,
|
||||||
#[prost(string, tag = "2")]
|
#[prost(string, tag = "2")]
|
||||||
pub format: ::prost::alloc::string::String,
|
pub format: ::prost::alloc::string::String,
|
||||||
#[prost(int64, tag = "3")]
|
#[prost(int32, tag = "3")]
|
||||||
pub size: i64,
|
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)]
|
#[derive(Clone, PartialEq, ::prost::Message)]
|
||||||
pub struct MonitoredRelease {
|
pub struct MonitoredRelease {
|
||||||
@@ -413,5 +468,34 @@ pub mod music_agregator_service_client {
|
|||||||
);
|
);
|
||||||
self.inner.unary(req, path, codec).await
|
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
@@ -1,2 +1,5 @@
|
|||||||
pub use crate::application::library_state::{LibraryFocus, LibraryState};
|
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 "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"│ │ "
|
||||||
|
"└──────────────────────────────┘ "
|
||||||
+10
-10
@@ -2,14 +2,13 @@
|
|||||||
source: tests/ui_snapshots.rs
|
source: tests/ui_snapshots.rs
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
"┌─[ Artists · 2 ]──────────────┐┌─[ Detail · UK · Alternative ]────────────────────────────────────┐"
|
"┌─[ Artists · 2 ]──────────────┐Radiohead "
|
||||||
"│◐ Radiohead 17/22 ││Radiohead │"
|
"│◐ Radiohead 17/22 │ "
|
||||||
"│! Pink Floyd 0/10 ││ │"
|
"│! Pink Floyd 0/10 │status ● Monitored path /music/Radiohead "
|
||||||
"│ ││status ● Monitored path /music/Radiohead │"
|
"│ │quality FLAC size 2.5 GB "
|
||||||
"│ ││quality FLAC size 2.5 GB │"
|
"│ │albums 2 tracks 17 / 22 "
|
||||||
"│ ││albums 2 tracks 17 / 22 │"
|
"│ │ "
|
||||||
"│ ││ │"
|
"│ │┌─[ Albums · 2 releases ]──────────────────────────────────────────┐"
|
||||||
"│ ││─ albums ─ 2 releases │"
|
|
||||||
"│ ││● OK Computer [Album] 1997 ▰▰▰▰▰▰▰▰▰▰ 12/12 FLAC │"
|
"│ ││● OK Computer [Album] 1997 ▰▰▰▰▰▰▰▰▰▰ 12/12 FLAC │"
|
||||||
"│ ││◐ Kid A [Album] 2000 ▰▰▰▰▰▱▱▱▱▱ 5/10 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) │"
|
"│ ││(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
@@ -1,7 +1,10 @@
|
|||||||
use ratatui::{Terminal, backend::TestBackend, widgets::Paragraph};
|
use ratatui::{Terminal, backend::TestBackend, widgets::Paragraph};
|
||||||
use ui_agregator::app::Tab;
|
use ui_agregator::app::Tab;
|
||||||
use ui_agregator::data::{Album, AlbumStatus, Artist, MonitorState};
|
use ui_agregator::data::{Album, AlbumStatus, Artist, MonitorState, Track};
|
||||||
use ui_agregator::ui::library::{LibraryFocus, LibraryState, render_library};
|
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::modals::render_help_modal;
|
||||||
use ui_agregator::ui::progress_bar::progress_bar;
|
use ui_agregator::ui::progress_bar::progress_bar;
|
||||||
use ui_agregator::ui::topbar::render_topbar;
|
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 {
|
mod progress_bar_snapshots {
|
||||||
use super::*;
|
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 {
|
mod help_modal_snapshots {
|
||||||
use super::*;
|
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::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user