diff --git a/CHANGELOG.md b/CHANGELOG.md index dafbb371..d657be0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > Patch versions are not included due to maintenance cost considerations. > More detailed version tracking may be described as the project matures. -## [Unreleased] +## [0.14.0] - 2026-07-28 + +### Added + +- Added configurable `TerminalSession` management for raw mode, alternate screen, cursor visibility, and mouse capture +- Added a prefix-search widget for composing filtered selection interfaces +- Added mouse-click support for text input, selection lists, checkboxes, trees, JSON, and YAML +- Added multiline text editing with continuation indentation and block submission, together with a REPL example +- Added a CSV table widget for navigating large files vertically and horizontally by display cell +- Added stable line numbers and viewport-aware projection to the JSON and YAML widgets +- Expanded terminal rendering regression coverage with [termharness](https://github.com/ynqa/termharness), running applications in a pseudo-terminal, replaying keyboard input and window resizing, and comparing the resulting screen with expected output +- Added renderer-managed widget layout and screen-to-widget hit testing + +### Changed + +- Shifted from framework-owned prompt presets to application-owned composition using reusable widget states and the optional `Prompt` runtime +- Reorganized Cargo features into runtime, terminal lifecycle, capabilities, and individual widgets +- Changed widgets to produce `CreatedGraphemes` with layout hints and logical cursor positions +- Improved renderer layout performance by extracting a terminal-independent layout engine and reducing layout cloning +- Improved structured JSON and YAML loading by parsing directly into rows and bounding projection to the visible viewport +- Updated `promkit-core` to `v0.5.0` and `promkit-widgets` to `v0.7.0` + +### Removed + +- Removed the built-in prompt presets and the generic widget cursor abstraction + +### Fixed + +- Preserved preceding terminal output and cleared stale rows correctly across resize and redraw cycles +- Preserved overwide graphemes during wrapping +- Stabilized JSON and YAML viewports, line numbers, root navigation, and keyed-container toggling +- Corrected CSV mouse scrolling behavior on macOS ## [0.13.0] - 2026-07-24 diff --git a/Concept.md b/Concept.md index fa69098f..888a9c40 100644 --- a/Concept.md +++ b/Concept.md @@ -28,6 +28,15 @@ The resulting development loop is: 4. Application-specific orchestration remains in the application, while examples document useful compositions. +The renderer defines three vertical sizing policies. `HeightPolicy::OrderedContent` +uses the wrapped content height, optionally capped by `max_height`, and allocates +available rows in widget order. `HeightPolicy::FairFill` shares the height +remaining after ordered-content items equally with other fair-fill items and +pads content to preserve the allocated area. `HeightPolicy::FairContent` joins +the same initial fair allocation but stops at its content height without +redistributing unused rows. Keeping these policies explicit avoids making +individual widgets reproduce container-level allocation policy. + For this reason, the `promkit` crate no longer owns preset implementations. It provides an optional `Prompt` lifecycle runtime, capabilities, and a widget facade for applications that find them useful, but it is not intended to replace @@ -145,7 +154,7 @@ Applications select the capabilities and widgets they need through Cargo features. The runtime is independent from the widget set: ```toml -promkit = { version = "0.14.0", features = [ +promkit = { version = "0.15.0", features = [ "runtime", "validate", "prefixsearch", @@ -171,7 +180,8 @@ that push grapheme changes directly to a shared renderer. ## Quality Strategy for Rendering Behavior Ensuring consistent rendering behavior across terminal environments is a key focus. -The [readline terminal scenarios](./tests/readline/tests/scenarios) use +The [terminal scenarios](./tests) use [`termharness`](https://github.com/ynqa/termharness) to verify wrapping, resizing, -cursor movement, and viewport behavior against recorded screen expectations. +cursor movement, viewport behavior, and height allocation against recorded +screen expectations. This keeps terminal behavior predictable while the rendering internals evolve. diff --git a/README.md b/README.md index 0d382705..a84183ea 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Put the package in your `Cargo.toml`. ```toml [dependencies] -promkit = { version = "0.14.0", features = ["runtime", "texteditor"] } +promkit = { version = "0.15.0", features = ["runtime", "texteditor"] } ``` ## Features @@ -30,7 +30,7 @@ promkit = { version = "0.14.0", features = ["runtime", "texteditor"] } resizing, and screen-to-widget hit testing. - **Reusable widget states** — [`promkit-widgets`](./promkit-widgets/) provides text editing and display, list and checkbox selection, prefix search, - spinners and status output, trees, JSON and YAML documents, and CSV tables. + spinners, trees, JSON and YAML documents, and CSV tables. - **Efficient large-content projection** — JSON, YAML, and table widgets can project only the visible terminal viewport instead of rebuilding all content on every redraw. diff --git a/examples/readline/src/evaluate.rs b/examples/readline/src/evaluate.rs index 251435bb..b939f8b7 100644 --- a/examples/readline/src/evaluate.rs +++ b/examples/readline/src/evaluate.rs @@ -73,13 +73,13 @@ fn click(column: u16, row: u16, ctx: &mut Readline) { } fn select_suggestion(index: usize, ctx: &mut Readline) { - if ctx.suggestions.prefix_search.move_to(index) { + if ctx.suggestions.result.move_to(index) { ctx.focus = Focus::Suggestion; } } fn apply_suggestion(ctx: &mut Readline) { - let Some(suggestion) = ctx.suggestions.prefix_search.get() else { + let Some(suggestion) = ctx.suggestions.result.get() else { dismiss_suggestions(ctx); return; }; @@ -89,7 +89,7 @@ fn apply_suggestion(ctx: &mut Readline) { } fn dismiss_suggestions(ctx: &mut Readline) { - ctx.suggestions.prefix_search.clear(); + ctx.suggestions.result.clear(); ctx.focus = Focus::Readline; } @@ -131,7 +131,8 @@ pub async fn readline(event: &Event, ctx: &mut Readline) -> anyhow::Result { let text = ctx.readline.texteditor.text_without_cursor().to_string(); - if ctx.suggestions.prefix_search.search(text) { + ctx.suggestions.result = ctx.prefix_search.query(text); + if !ctx.suggestions.result.is_empty() { ctx.focus = Focus::Suggestion; } else { dismiss_suggestions(ctx); @@ -279,7 +280,7 @@ pub async fn suggestion(event: &Event, ctx: &mut Readline) -> anyhow::Result { - ctx.suggestions.prefix_search.forward(); + ctx.suggestions.result.forward(); } Event::Key(KeyEvent { code: KeyCode::Up, @@ -287,7 +288,7 @@ pub async fn suggestion(event: &Event, ctx: &mut Readline) -> anyhow::Result { - ctx.suggestions.prefix_search.backward(); + ctx.suggestions.result.backward(); } _ => { let before = ctx.readline.texteditor.text_without_cursor(); diff --git a/examples/readline/src/lib.rs b/examples/readline/src/lib.rs index 682625c4..e76dd913 100644 --- a/examples/readline/src/lib.rs +++ b/examples/readline/src/lib.rs @@ -46,6 +46,7 @@ pub struct Readline { pub focus: Focus, pub title: text::State, pub readline: text_editor::State, + pub prefix_search: PrefixSearch, pub suggestions: prefix_search::State, pub validator: Option>, pub error_message: text::State, @@ -88,8 +89,9 @@ impl Default for Readline { lines: Default::default(), }, }, + prefix_search: PrefixSearch::default(), suggestions: prefix_search::State { - prefix_search: PrefixSearch::default(), + result: Default::default(), config: prefix_search::Config { cursor: String::from("❯ "), active_item_style: Some(ContentStyle { @@ -114,6 +116,7 @@ impl Default for Readline { ..Default::default() }), lines: None, + ..Default::default() }, }, } diff --git a/examples/readline/src/readline.rs b/examples/readline/src/readline.rs index f50f8f8e..60fa1b9b 100644 --- a/examples/readline/src/readline.rs +++ b/examples/readline/src/readline.rs @@ -9,8 +9,7 @@ use readline::Readline; async fn main() -> anyhow::Result<()> { let mut prompt = Readline::default(); prompt.title.text = Text::from("Hi!"); - prompt.suggestions.prefix_search = - PrefixSearch::from_iter(["apple", "applet", "application", "banana"]); + prompt.prefix_search = PrefixSearch::from_iter(["apple", "applet", "application", "banana"]); prompt.validator = Some(ValidatorManager::new( |text| text.len() > 10, |text| format!("Length must be over 10 but got {}", text.len()), diff --git a/promkit-core/Cargo.toml b/promkit-core/Cargo.toml index a5eadc74..d546da35 100644 --- a/promkit-core/Cargo.toml +++ b/promkit-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "promkit-core" -version = "0.5.0" +version = "0.6.0" edition = "2024" authors = ["ynqa "] description = "Core library for promkit" diff --git a/promkit-core/benches/renderer_layout.rs b/promkit-core/benches/renderer_layout.rs index 68228b8b..37a73bb9 100644 --- a/promkit-core/benches/renderer_layout.rs +++ b/promkit-core/benches/renderer_layout.rs @@ -202,6 +202,7 @@ fn created_fixture( layout: WidgetLayout { max_height: None, width_mode, + ..Default::default() }, cursor: Some(ContentPosition { row: cursor_row, diff --git a/promkit-core/src/lib.rs b/promkit-core/src/lib.rs index ceccce62..a3f5d8be 100644 --- a/promkit-core/src/lib.rs +++ b/promkit-core/src/lib.rs @@ -7,6 +7,6 @@ pub mod terminal; pub mod widget; pub use widget::{ - ContentPosition, CreatedGraphemes, ScreenPosition, ViewportChange, VisualPosition, Widget, - WidgetLayout, WidgetPosition, WidgetViewport, WidthMode, + ContentPosition, CreatedGraphemes, HeightPolicy, ScreenPosition, ViewportChange, + VisualPosition, Widget, WidgetLayout, WidgetPosition, WidgetViewport, WidthMode, }; diff --git a/promkit-core/src/render.rs b/promkit-core/src/render.rs index 0052484b..60363b31 100644 --- a/promkit-core/src/render.rs +++ b/promkit-core/src/render.rs @@ -6,9 +6,13 @@ //! include its cursor, and delegates the resulting visible rows to the terminal. //! //! Empty items and items with `max_height == Some(0)` do not occupy space. -//! Remaining items are allocated in key order while reserving at least one row -//! for every later non-empty item. A terminal that cannot provide one row per -//! non-empty item produces an error. +//! Ordered-content items are allocated in key order while reserving at least one +//! row for every non-empty item. The remaining height is shared equally between +//! items using [`crate::HeightPolicy::FairContent`] or +//! [`crate::HeightPolicy::FairFill`]. Fair-content items stop at their content +//! height without redistributing their unused share, while fair-fill items pad +//! their content to preserve the allocated area. A terminal that cannot provide +//! one row per non-empty item produces an error. //! //! A successful render saves a layout snapshot. [`Renderer::hit_test`] and //! [`Renderer::screen_position`] always use that snapshot, so event handling maps diff --git a/promkit-core/src/render/layout.rs b/promkit-core/src/render/layout.rs index 4146f80d..eb9051fc 100644 --- a/promkit-core/src/render/layout.rs +++ b/promkit-core/src/render/layout.rs @@ -3,11 +3,14 @@ use std::collections::BTreeMap; use crate::{ grapheme::StyledGraphemes, widget::{ - ContentPosition, CreatedGraphemes, ScreenPosition, VisualPosition, WidgetViewport, - WidthMode, + ContentPosition, CreatedGraphemes, HeightPolicy, ScreenPosition, VisualPosition, + WidgetLayout, WidgetViewport, WidthMode, }, }; +mod height; +use height::HeightRequest; + /// Terminal-size-dependent renderer layout without terminal I/O. /// /// The layout keeps each pane's vertical viewport offset between calls. This @@ -33,6 +36,28 @@ pub(super) struct VisualRow { pub(super) graphemes: StyledGraphemes, } +#[derive(Clone, Debug)] +struct LaidOutPane { + index: K, + layout: WidgetLayout, + cursor: Option, + rows: Vec, +} + +impl LaidOutPane { + fn occupies_space(&self) -> bool { + !self.rows.is_empty() && self.layout.max_height != Some(0) + } + + fn height_request(&self) -> HeightRequest { + HeightRequest::new( + self.layout.height_policy, + self.rows.len(), + self.layout.max_height, + ) + } +} + #[derive(Clone, Debug)] pub(super) struct LayoutEntry { pub(super) index: K, @@ -81,7 +106,8 @@ impl PreparedLayout { self.entries.len() } - /// Returns the number of visual rows produced before viewport clipping. + /// Returns the number of visual rows retained before viewport clipping, + /// including empty rows reserved by fill-sized panes. pub fn visual_row_count(&self) -> usize { self.entries.iter().map(|entry| entry.rows.len()).sum() } @@ -139,50 +165,58 @@ impl RendererLayout { cursor, } = created; let rows = layout_content(graphemes, layout.width_mode, terminal_width as usize); - (index, layout, cursor, rows) + LaidOutPane { + index, + layout, + cursor, + rows, + } }) - .filter(|(_, layout, _, rows)| !rows.is_empty() && layout.max_height != Some(0)) + .filter(LaidOutPane::occupies_space) .collect::>(); if laid_out.len() > terminal_height as usize { return Err(anyhow::anyhow!("Insufficient space to display all panes")); } - let mut entries = Vec::with_capacity(laid_out.len()); - let mut used_height = 0usize; let pane_count = laid_out.len(); + let height_requests = laid_out + .iter() + .map(LaidOutPane::height_request) + .collect::>(); + let heights = height::allocate(&height_requests, terminal_height as usize); + let mut entries = Vec::with_capacity(pane_count); - for (pane_index, (index, layout, cursor, rows)) in laid_out.into_iter().enumerate() { - let panes_after = pane_count.saturating_sub(pane_index + 1); - let available = (terminal_height as usize) - .saturating_sub(used_height) - .saturating_sub(panes_after); - let desired = layout.max_height.unwrap_or(rows.len()).min(rows.len()); - let height = desired.min(available).max(1); - used_height = used_height.saturating_add(height); - + for (mut pane, height) in laid_out.into_iter().zip(heights) { + if pane.layout.height_policy == HeightPolicy::FairFill && pane.rows.len() < height { + pad_rows_to_height(&mut pane.rows, height); + } let mut viewport = WidgetViewport { height: height as u16, - content_row: self.viewport_rows.get(&index).copied().unwrap_or_default(), + content_row: self + .viewport_rows + .get(&pane.index) + .copied() + .unwrap_or_default(), ..Default::default() }; - let max_content_row = rows.len().saturating_sub(height); + let max_content_row = pane.rows.len().saturating_sub(height); viewport.content_row = viewport.content_row.min(max_content_row); - if let Some(cursor) = cursor - && let Some(position) = visual_position(&rows, cursor) + if let Some(cursor) = pane.cursor + && let Some(position) = visual_position(&pane.rows, cursor) { viewport.scroll_to_include(position); viewport.content_row = viewport.content_row.min(max_content_row); } self.viewport_rows - .insert(index.clone(), viewport.content_row); + .insert(pane.index.clone(), viewport.content_row); entries.push(LayoutEntry { - index, + index: pane.index, viewport, - rows, + rows: pane.rows, }); } @@ -197,6 +231,21 @@ impl RendererLayout { } } +fn pad_rows_to_height(rows: &mut Vec, height: usize) { + let first_padding_row = rows + .last() + .map_or(0, |row| row.content_row.saturating_add(1)); + rows.extend( + (first_padding_row..) + .take(height.saturating_sub(rows.len())) + .map(|content_row| VisualRow { + content_row, + content_column: 0, + graphemes: StyledGraphemes::default(), + }), + ); +} + fn layout_content( graphemes: StyledGraphemes, width_mode: WidthMode, @@ -509,6 +558,50 @@ mod tests { let second_panes = second.panes(); assert_eq!(second_panes[0][0].to_string(), "second"); } + + #[test] + fn fills_the_allocated_height_beyond_content() { + let created = || CreatedGraphemes { + graphemes: StyledGraphemes::from("content"), + layout: WidgetLayout { + height_policy: HeightPolicy::FairFill, + ..Default::default() + }, + cursor: None, + }; + let mut layout = RendererLayout::default(); + + let prepared = layout + .layout([(0, created()), (1, created())], 80, 6) + .unwrap(); + + assert_eq!( + prepared.panes().iter().map(Vec::len).collect::>(), + [3, 3] + ); + } + + #[test] + fn does_not_pad_fair_content_beyond_content_height() { + let created = || CreatedGraphemes { + graphemes: StyledGraphemes::from("content"), + layout: WidgetLayout { + height_policy: HeightPolicy::FairContent, + ..Default::default() + }, + cursor: None, + }; + let mut layout = RendererLayout::default(); + + let prepared = layout + .layout([(0, created()), (1, created())], 80, 6) + .unwrap(); + + assert_eq!( + prepared.panes().iter().map(Vec::len).collect::>(), + [1, 1] + ); + } } } } diff --git a/promkit-core/src/render/layout/height.rs b/promkit-core/src/render/layout/height.rs new file mode 100644 index 00000000..db791dbf --- /dev/null +++ b/promkit-core/src/render/layout/height.rs @@ -0,0 +1,209 @@ +//! Vertical allocation for already-laid-out, non-empty panes. +//! +//! Every pane first reserves one row. Ordered-content panes then consume rows in +//! pane order. Fair panes split what remains into equal shares. Finally, +//! fair-content panes shrink to their content without returning unused rows, +//! while rows released by a capped fair-fill pane may move to another fair-fill +//! pane. + +use crate::HeightPolicy; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct HeightRequest { + policy: HeightPolicy, + content_height: usize, + max_height: Option, +} + +impl HeightRequest { + pub(super) fn new( + policy: HeightPolicy, + content_height: usize, + max_height: Option, + ) -> Self { + Self { + policy, + content_height, + max_height, + } + } + + fn is_fair(self) -> bool { + matches!( + self.policy, + HeightPolicy::FairContent | HeightPolicy::FairFill + ) + } + + fn content_limit(self) -> usize { + self.max_height + .unwrap_or(self.content_height) + .min(self.content_height) + .max(1) + } + + fn fill_limit(self, available: usize) -> usize { + self.max_height.unwrap_or(available).max(1) + } +} + +pub(super) fn allocate(requests: &[HeightRequest], available: usize) -> Vec { + debug_assert!(requests.len() <= available); + + let mut heights = vec![1; requests.len()]; + let remaining = available.saturating_sub(requests.len()); + let remaining = allocate_ordered_content(requests, &mut heights, remaining); + + allocate_equal_fair_shares(requests, &mut heights, remaining); + apply_fair_limits(requests, &mut heights, available); + + heights +} + +fn allocate_ordered_content( + requests: &[HeightRequest], + heights: &mut [usize], + mut remaining: usize, +) -> usize { + for (index, request) in requests.iter().copied().enumerate() { + if request.policy != HeightPolicy::OrderedContent { + continue; + } + + let extra = request.content_limit().saturating_sub(1).min(remaining); + heights[index] += extra; + remaining -= extra; + } + + remaining +} + +fn allocate_equal_fair_shares(requests: &[HeightRequest], heights: &mut [usize], remaining: usize) { + let fair_count = requests.iter().filter(|request| request.is_fair()).count(); + if fair_count == 0 { + return; + } + + let rows_per_pane = remaining / fair_count; + let extra_panes = remaining % fair_count; + + let mut fair_index = 0; + for (index, request) in requests.iter().copied().enumerate() { + if !request.is_fair() { + continue; + } + + heights[index] += rows_per_pane + usize::from(fair_index < extra_panes); + fair_index += 1; + } +} + +fn apply_fair_limits(requests: &[HeightRequest], heights: &mut [usize], available: usize) { + let mut redistributable_fill_rows = 0; + + for (index, request) in requests.iter().copied().enumerate() { + match request.policy { + HeightPolicy::OrderedContent => {} + HeightPolicy::FairContent => { + // Its equal share is an upper bound; unused rows stay unused. + heights[index] = heights[index].min(request.content_limit()); + } + HeightPolicy::FairFill => { + let limit = request.fill_limit(available); + // A max-height cap releases fill rows for other fill panes. + redistributable_fill_rows += heights[index].saturating_sub(limit); + heights[index] = heights[index].min(limit); + } + } + } + + redistribute_fair_fill_rows(requests, heights, available, redistributable_fill_rows); +} + +fn redistribute_fair_fill_rows( + requests: &[HeightRequest], + heights: &mut [usize], + available: usize, + mut remaining: usize, +) { + while remaining > 0 { + let mut distributed = false; + + for (index, request) in requests.iter().copied().enumerate() { + if request.policy != HeightPolicy::FairFill + || heights[index] >= request.fill_limit(available) + { + continue; + } + + heights[index] += 1; + remaining -= 1; + distributed = true; + if remaining == 0 { + break; + } + } + + if !distributed { + break; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(policy: HeightPolicy, content_height: usize) -> HeightRequest { + HeightRequest::new(policy, content_height, None) + } + + mod allocate { + use super::super::allocate as allocate_heights; + use super::*; + + #[test] + fn preserves_content_allocation_in_key_order() { + let requests = [ + request(HeightPolicy::OrderedContent, 10), + request(HeightPolicy::OrderedContent, 10), + request(HeightPolicy::OrderedContent, 10), + ]; + + assert_eq!(allocate_heights(&requests, 8), [6, 1, 1]); + } + + #[test] + fn shares_height_equally_between_fair_fill_entries() { + let requests = [ + request(HeightPolicy::OrderedContent, 2), + request(HeightPolicy::FairFill, 1), + request(HeightPolicy::FairFill, 1), + ]; + + assert_eq!(allocate_heights(&requests, 10), [2, 4, 4]); + } + + #[test] + fn reallocates_height_after_a_fair_fill_entry_reaches_its_limit() { + let requests = [ + request(HeightPolicy::OrderedContent, 2), + request(HeightPolicy::FairFill, 1), + HeightRequest::new(HeightPolicy::FairFill, 1, Some(3)), + ]; + + assert_eq!(allocate_heights(&requests, 12), [2, 7, 3]); + } + + #[test] + fn keeps_fair_content_within_its_equal_share() { + let requests = [ + request(HeightPolicy::FairContent, 1), + request(HeightPolicy::FairContent, 10), + request(HeightPolicy::FairContent, 10), + ]; + + assert_eq!(allocate_heights(&requests, 8), [1, 3, 2]); + } + } +} diff --git a/promkit-core/src/widget.rs b/promkit-core/src/widget.rs index 7d17d4f3..d1ff3cf8 100644 --- a/promkit-core/src/widget.rs +++ b/promkit-core/src/widget.rs @@ -67,14 +67,34 @@ pub enum WidthMode { Truncate, } +/// Vertical sizing behavior applied by the renderer. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum HeightPolicy { + /// Allocate content height in widget order, subject to + /// [`WidgetLayout::max_height`] and the remaining terminal height. + #[default] + OrderedContent, + /// Take at most an equal share of the remaining height, then shrink to the + /// content height without redistributing the unused share. + FairContent, + /// Share the height left after ordered-content widgets fairly with other + /// fill widgets, subject to [`WidgetLayout::max_height`]. + FairFill, +} + /// Layout constraints requested by a widget. /// /// `max_height` is a preference rather than a terminal allocation. The renderer /// combines it with the laid-out content height, terminal height, and the other -/// non-empty widgets. `width_mode` controls whether each logical row wraps or is -/// truncated with an ellipsis. +/// non-empty widgets. [`HeightPolicy::OrderedContent`] uses that content-derived +/// height in widget order, while [`HeightPolicy::FairFill`] shares the remaining +/// terminal height equally with other fair-sized widgets. +/// [`HeightPolicy::FairContent`] uses the same initial fair allocation but stops +/// at its content height without redistributing unused rows. `width_mode` +/// controls whether each logical row wraps or is truncated with an ellipsis. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct WidgetLayout { + pub height_policy: HeightPolicy, pub max_height: Option, pub width_mode: WidthMode, } diff --git a/promkit-widgets/Cargo.toml b/promkit-widgets/Cargo.toml index dc01fecb..5689a7c8 100644 --- a/promkit-widgets/Cargo.toml +++ b/promkit-widgets/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "promkit-widgets" -version = "0.7.0" +version = "0.8.0" edition = "2024" authors = ["ynqa "] description = "Widgets for promkit" @@ -10,7 +10,7 @@ readme = "README.md" [features] default = [] -all = ["checkbox", "json", "yaml", "listbox", "prefixsearch", "serde", "spinner", "status", "table", "text", "texteditor", "tree"] +all = ["checkbox", "json", "yaml", "listbox", "prefixsearch", "serde", "spinner", "table", "text", "texteditor", "tree"] checkbox = ["listbox"] json = ["dep:serde", "dep:serde_json", "dep:rayon"] yaml = ["dep:serde", "dep:serde_yaml", "dep:rayon"] @@ -18,7 +18,6 @@ listbox = [] prefixsearch = ["dep:radix_trie"] serde = ["dep:serde", "dep:termcfg"] spinner = ["dep:tokio"] -status = ["text"] table = ["dep:csv", "dep:unicode-width"] text = [] texteditor = [] @@ -26,7 +25,7 @@ tree = [] [dependencies] anyhow = { workspace = true } -promkit-core = { path = "../promkit-core", version = "=0.5.0" } +promkit-core = { path = "../promkit-core", version = "=0.6.0" } # Optional dependencies csv = { workspace = true, optional = true } diff --git a/promkit-widgets/README.md b/promkit-widgets/README.md index 63f9377d..d1e2ffb1 100644 --- a/promkit-widgets/README.md +++ b/promkit-widgets/README.md @@ -14,14 +14,14 @@ using the `promkit` runtime can enable them through the main crate: ```toml [dependencies] -promkit = { version = "0.14.0", features = ["runtime", "texteditor"] } +promkit = { version = "0.15.0", features = ["runtime", "texteditor"] } ``` The widget states can also be used directly: ```toml [dependencies] -promkit-widgets = { version = "0.7", features = ["json", "yaml"] } +promkit-widgets = { version = "0.8", features = ["json", "yaml"] } ``` `promkit` re-exports this crate as `promkit::widgets`, while @@ -41,7 +41,6 @@ promkit-widgets = { version = "0.7", features = ["json", "yaml"] } | `text` | Styled text | | `texteditor` | Editable text with history | | `spinner` | Asynchronous progress display | -| `status` | Status display | | `serde` | Serde support for widget configuration | | `all` | All features above | diff --git a/promkit-widgets/src/lib.rs b/promkit-widgets/src/lib.rs index e999fe98..ca9205fc 100644 --- a/promkit-widgets/src/lib.rs +++ b/promkit-widgets/src/lib.rs @@ -39,10 +39,6 @@ pub mod table; #[cfg_attr(docsrs, doc(cfg(feature = "text")))] pub mod text; -#[cfg(feature = "status")] -#[cfg_attr(docsrs, doc(cfg(feature = "status")))] -pub mod status; - #[cfg(feature = "texteditor")] #[cfg_attr(docsrs, doc(cfg(feature = "texteditor")))] pub mod text_editor; diff --git a/promkit-widgets/src/prefix_search.rs b/promkit-widgets/src/prefix_search.rs index ec33fba6..83600f37 100644 --- a/promkit-widgets/src/prefix_search.rs +++ b/promkit-widgets/src/prefix_search.rs @@ -4,15 +4,15 @@ use promkit_core::{ #[path = "prefix_search/prefix_search.rs"] mod inner; -pub use inner::PrefixSearch; +pub use inner::{PrefixSearch, PrefixSearchResult}; pub mod config; pub use config::Config; -/// State for searching, selecting, and rendering prefix-matched candidates. +/// State for selecting and rendering a prefix-search result. #[derive(Clone)] pub struct State { - /// Prefix-search data and its current selection. - pub prefix_search: PrefixSearch, + /// The candidate snapshot and its current selection. + pub result: PrefixSearchResult, /// Rendering configuration. pub config: Config, } @@ -22,11 +22,11 @@ impl Widget for State { let cursor = StyledGraphemes::from(&self.config.cursor); let cursor_width = cursor.widths(); let lines = self - .prefix_search + .result .candidates() .enumerate() .map(|(index, candidate)| { - if Some(index) == self.prefix_search.selected() { + if Some(index) == self.result.selected() { let line = StyledGraphemes::from_iter([&cursor, &StyledGraphemes::from(candidate)]); if let Some(style) = self.config.active_item_style { @@ -53,13 +53,10 @@ impl Widget for State { max_height: self.config.lines, ..Default::default() }, - cursor: self - .prefix_search - .selected() - .map(|selected| ContentPosition { - row: selected, - column: 0, - }), + cursor: self.result.selected().map(|selected| ContentPosition { + row: selected, + column: 0, + }), } } } @@ -67,7 +64,7 @@ impl Widget for State { impl State { /// Interprets a content position as a prefix-search candidate. pub fn hit_at(&self, position: ContentPosition) -> Option { - self.prefix_search + self.result .candidate_at(position.row) .map(|_| PrefixSearchHit::Select { index: position.row, @@ -95,12 +92,11 @@ mod tests { #[test] fn projects_trie_matches_and_tracks_hit_rows() { - let mut prefix_search: PrefixSearch = ["apple", "applet", "application", "banana"] + let prefix_search: PrefixSearch = ["apple", "applet", "application", "banana"] .into_iter() .collect(); - prefix_search.search("app"); let mut state = State { - prefix_search, + result: prefix_search.query("app"), config: Config { lines: Some(3), ..Default::default() @@ -121,7 +117,7 @@ mod tests { ); assert_eq!(state.hit_at(ContentPosition { row: 3, column: 0 }), None); - state.prefix_search.move_to(1); + state.result.move_to(1); let created = state.create_graphemes(); assert_eq!( diff --git a/promkit-widgets/src/prefix_search/prefix_search.rs b/promkit-widgets/src/prefix_search/prefix_search.rs index fba29407..61cda151 100644 --- a/promkit-widgets/src/prefix_search/prefix_search.rs +++ b/promkit-widgets/src/prefix_search/prefix_search.rs @@ -1,63 +1,88 @@ -use std::{fmt, iter::FromIterator}; +use std::{fmt, iter::FromIterator, sync::Arc}; use radix_trie::{Trie, TrieCommon}; /// Prefix-search candidates backed directly by a radix trie. #[derive(Clone)] pub struct PrefixSearch { - candidates: Trie, - query: Option, - selected: Option, + candidates: Arc<[String]>, + index: Trie, } impl Default for PrefixSearch { fn default() -> Self { Self { - candidates: Trie::new(), - query: None, - selected: None, + candidates: Arc::default(), + index: Trie::new(), } } } impl FromIterator for PrefixSearch { fn from_iter>(iter: I) -> Self { - Self { - candidates: Trie::from_iter(iter.into_iter().map(|item| (item.to_string(), ()))), - ..Default::default() - } + let candidates = iter + .into_iter() + .map(|item| item.to_string()) + .collect::>(); + let index = Trie::from_iter( + candidates + .iter() + .enumerate() + .map(|(index, candidate)| (candidate.clone(), index)), + ); + + Self { candidates, index } } } impl PrefixSearch { - /// Updates the active query and selects the first matching candidate. - /// - /// Returns `true` when at least one candidate matches. - pub fn search(&mut self, query: impl AsRef) -> bool { - self.query = Some(query.as_ref().to_string()); - let has_match = self.candidates().next().is_some(); - self.selected = has_match.then_some(0); - has_match + /// Creates an independently selectable snapshot of candidates matching `query`. + pub fn query(&self, query: impl AsRef) -> PrefixSearchResult { + let candidates = self + .index + .get_raw_descendant(query.as_ref()) + .into_iter() + .flat_map(|subtrie| subtrie.iter().map(|(_, index)| *index)) + .collect::>(); + let selected = (!candidates.is_empty()).then_some(0); + + PrefixSearchResult { + source: Arc::clone(&self.candidates), + candidates, + selected, + } } +} + +/// A selectable snapshot produced by a prefix query. +#[derive(Clone, Default)] +pub struct PrefixSearchResult { + source: Arc<[String]>, + candidates: Vec, + selected: Option, +} - /// Clears the active query and selection without removing candidates. +impl PrefixSearchResult { + /// Clears the snapshot and its selection. pub fn clear(&mut self) { - self.query = None; + self.candidates.clear(); self.selected = None; } - /// Returns candidates matching the active query directly from the trie. + /// Returns the candidates in this snapshot. pub fn candidates(&self) -> impl Iterator { - self.query - .as_deref() - .and_then(|query| self.candidates.get_raw_descendant(query)) - .into_iter() - .flat_map(|subtrie| subtrie.iter().map(|(candidate, _)| candidate.as_str())) + self.candidates + .iter() + .filter_map(|index| self.source.get(*index)) + .map(String::as_str) } - /// Returns the candidate at `index` in the current match set. + /// Returns the candidate at `index`. pub fn candidate_at(&self, index: usize) -> Option<&str> { - self.candidates().nth(index) + self.candidates + .get(index) + .and_then(|index| self.source.get(*index)) + .map(String::as_str) } /// Returns the selected candidate index. @@ -71,9 +96,9 @@ impl PrefixSearch { .and_then(|selected| self.candidate_at(selected)) } - /// Returns whether the current match set is empty. + /// Returns whether the snapshot is empty. pub fn is_empty(&self) -> bool { - self.candidates().next().is_none() + self.candidates.is_empty() } /// Moves the selection to the previous candidate. @@ -89,7 +114,7 @@ impl PrefixSearch { pub fn forward(&mut self) -> bool { let Some(selected) = self .selected - .filter(|selected| self.candidate_at(selected.saturating_add(1)).is_some()) + .filter(|selected| selected.saturating_add(1) < self.candidates.len()) else { return false; }; @@ -99,7 +124,7 @@ impl PrefixSearch { /// Moves the selection to a candidate by index. pub fn move_to(&mut self, index: usize) -> bool { - if self.candidate_at(index).is_some() { + if index < self.candidates.len() { self.selected = Some(index); true } else { @@ -110,7 +135,7 @@ impl PrefixSearch { #[cfg(test)] mod tests { - use super::PrefixSearch; + use super::{PrefixSearch, PrefixSearchResult}; fn prefix_search() -> PrefixSearch { ["apple", "applet", "application", "banana"] @@ -118,109 +143,109 @@ mod tests { .collect() } - mod search { + mod prefix_search { use super::*; - #[test] - fn filters_the_trie_and_selects_the_first_match() { - let mut prefix_search = prefix_search(); - - assert!(prefix_search.search("app")); - assert_eq!( - prefix_search.candidates().collect::>(), - vec!["apple", "applet", "application"] - ); - assert_eq!(prefix_search.selected(), Some(0)); - assert_eq!(prefix_search.get(), Some("apple")); - - assert!(prefix_search.search("ban")); - assert_eq!( - prefix_search.candidates().collect::>(), - vec!["banana"] - ); - assert_eq!(prefix_search.selected(), Some(0)); - assert_eq!(prefix_search.get(), Some("banana")); + mod query { + use super::*; + + #[test] + fn returns_an_independently_selectable_snapshot() { + let prefix_search = prefix_search(); + + let mut application = prefix_search.query("app"); + let banana = prefix_search.query("ban"); + application.forward(); + + assert_eq!( + application.candidates().collect::>(), + vec!["apple", "applet", "application"] + ); + assert_eq!(application.get(), Some("applet")); + assert_eq!(banana.candidates().collect::>(), vec!["banana"]); + assert_eq!(banana.get(), Some("banana")); + } + + #[test] + fn returns_an_empty_snapshot_for_a_missing_prefix() { + let result = prefix_search().query("orange"); + + assert!(result.is_empty()); + assert_eq!(result.selected(), None); + assert_eq!(result.get(), None); + } } + } - #[test] - fn missing_match_clears_the_selection() { - let mut prefix_search = prefix_search(); + mod prefix_search_result { + use super::*; - assert!(prefix_search.search("app")); - assert!(!prefix_search.search("orange")); - assert!(prefix_search.is_empty()); - assert_eq!(prefix_search.selected(), None); - assert_eq!(prefix_search.get(), None); + fn result() -> PrefixSearchResult { + prefix_search().query("app") } - } - mod backward { - use super::*; + mod backward { + use super::*; - #[test] - fn stops_at_the_first_match() { - let mut prefix_search = prefix_search(); - prefix_search.search("app"); + #[test] + fn stops_at_the_first_candidate() { + let mut result = result(); - assert!(!prefix_search.backward()); - assert_eq!(prefix_search.get(), Some("apple")); + assert!(!result.backward()); + assert_eq!(result.get(), Some("apple")); + } } - } - mod forward { - use super::*; + mod forward { + use super::*; - #[test] - fn stops_at_the_last_match() { - let mut prefix_search = prefix_search(); - prefix_search.search("app"); + #[test] + fn stops_at_the_last_candidate() { + let mut result = result(); - assert!(prefix_search.forward()); - assert_eq!(prefix_search.get(), Some("applet")); - assert!(prefix_search.forward()); - assert_eq!(prefix_search.get(), Some("application")); - assert!(!prefix_search.forward()); + assert!(result.forward()); + assert_eq!(result.get(), Some("applet")); + assert!(result.forward()); + assert_eq!(result.get(), Some("application")); + assert!(!result.forward()); + } } - } - mod move_to { - use super::*; + mod move_to { + use super::*; - #[test] - fn rejects_an_out_of_bounds_index() { - let mut prefix_search = prefix_search(); - prefix_search.search("app"); - prefix_search.move_to(2); + #[test] + fn rejects_an_out_of_bounds_index() { + let mut result = result(); + result.move_to(2); - assert!(!prefix_search.move_to(3)); - assert_eq!(prefix_search.selected(), Some(2)); - } + assert!(!result.move_to(3)); + assert_eq!(result.selected(), Some(2)); + } - #[test] - fn selects_the_given_match() { - let mut prefix_search = prefix_search(); - prefix_search.search("app"); - prefix_search.move_to(2); + #[test] + fn selects_the_given_candidate() { + let mut result = result(); + result.move_to(2); - assert!(prefix_search.move_to(0)); - assert_eq!(prefix_search.get(), Some("apple")); + assert!(result.move_to(0)); + assert_eq!(result.get(), Some("apple")); + } } - } - mod clear { - use super::*; + mod clear { + use super::*; - #[test] - fn hides_matches_without_removing_candidates() { - let mut prefix_search = prefix_search(); - prefix_search.search("app"); + #[test] + fn removes_candidates_and_selection_from_the_snapshot() { + let mut result = result(); - prefix_search.clear(); + result.clear(); - assert!(prefix_search.is_empty()); - assert_eq!(prefix_search.selected(), None); - assert!(prefix_search.search("app")); - assert_eq!(prefix_search.get(), Some("apple")); + assert!(result.is_empty()); + assert_eq!(result.selected(), None); + assert_eq!(result.get(), None); + } } } } diff --git a/promkit-widgets/src/status.rs b/promkit-widgets/src/status.rs deleted file mode 100644 index 1b5b30fe..00000000 --- a/promkit-widgets/src/status.rs +++ /dev/null @@ -1,61 +0,0 @@ -use promkit_core::{ - CreatedGraphemes, Widget, - crossterm::style::{Color, ContentStyle}, -}; - -use crate::text::{State as TextState, Text}; - -/// Represents status levels shown by a wrapped text widget. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum Severity { - #[default] - Success, - Warning, - Error, -} - -impl Severity { - pub fn style(self) -> ContentStyle { - ContentStyle { - foreground_color: Some(match self { - Self::Success => Color::Green, - Self::Warning => Color::Yellow, - Self::Error => Color::Red, - }), - ..Default::default() - } - } -} - -/// Wraps `text::State` and applies a color style based on severity. -#[derive(Clone)] -pub struct State { - pub text: TextState, - pub severity: Severity, -} - -impl Default for State { - fn default() -> Self { - Self::new("", Severity::Success) - } -} - -impl State { - pub fn new>(text: T, severity: Severity) -> Self { - let mut state = Self { - text: TextState { - text: Text::from(text), - ..Default::default() - }, - severity, - }; - state.text.config.style = Some(state.severity.style()); - state - } -} - -impl Widget for State { - fn create_graphemes(&self) -> CreatedGraphemes { - self.text.create_graphemes() - } -} diff --git a/promkit-widgets/src/structured/json.rs b/promkit-widgets/src/structured/json.rs index 925798c1..1edaabd7 100644 --- a/promkit-widgets/src/structured/json.rs +++ b/promkit-widgets/src/structured/json.rs @@ -73,6 +73,7 @@ impl State { config::OverflowMode::Truncate => WidthMode::Truncate, config::OverflowMode::Wrap => WidthMode::Wrap, }, + ..Default::default() }, cursor: (!rows.is_empty()).then_some(ContentPosition { row: active_row, diff --git a/promkit-widgets/src/structured/yaml.rs b/promkit-widgets/src/structured/yaml.rs index e51bc5c2..8a9f62a6 100644 --- a/promkit-widgets/src/structured/yaml.rs +++ b/promkit-widgets/src/structured/yaml.rs @@ -66,6 +66,7 @@ impl State { config::OverflowMode::Truncate => WidthMode::Truncate, config::OverflowMode::Wrap => WidthMode::Wrap, }, + ..Default::default() }, cursor: (!rows.is_empty()).then_some(ContentPosition { row: active_row, diff --git a/promkit-widgets/src/table.rs b/promkit-widgets/src/table.rs index f49588fd..24a79cd8 100644 --- a/promkit-widgets/src/table.rs +++ b/promkit-widgets/src/table.rs @@ -94,6 +94,7 @@ impl State { layout: WidgetLayout { max_height: self.config.lines, width_mode: WidthMode::Truncate, + ..Default::default() }, ..CreatedGraphemes::default() }; @@ -153,6 +154,7 @@ impl State { layout: WidgetLayout { max_height: self.config.lines, width_mode: WidthMode::Truncate, + ..Default::default() }, cursor, } diff --git a/promkit-widgets/src/text.rs b/promkit-widgets/src/text.rs index aeac507f..4745d7c7 100644 --- a/promkit-widgets/src/text.rs +++ b/promkit-widgets/src/text.rs @@ -1,5 +1,5 @@ use promkit_core::{ - ContentPosition, CreatedGraphemes, Widget, WidgetLayout, grapheme::StyledGraphemes, + ContentPosition, CreatedGraphemes, Widget, WidgetLayout, WidthMode, grapheme::StyledGraphemes, }; #[path = "text/text.rs"] @@ -44,6 +44,10 @@ impl Widget for State { graphemes: StyledGraphemes::from_lines(lines), layout: WidgetLayout { max_height: self.config.lines, + width_mode: match self.config.overflow_mode { + config::OverflowMode::Truncate => WidthMode::Truncate, + config::OverflowMode::Wrap => WidthMode::Wrap, + }, ..Default::default() }, cursor: self @@ -85,6 +89,28 @@ mod tests { mod state { use super::*; + mod create_graphemes { + use super::*; + + #[test] + fn uses_configured_overflow_mode() { + for (overflow_mode, width_mode) in [ + (config::OverflowMode::Truncate, WidthMode::Truncate), + (config::OverflowMode::Wrap, WidthMode::Wrap), + ] { + let state = State { + text: Text::from("text"), + config: Config { + overflow_mode, + ..Default::default() + }, + }; + + assert_eq!(state.create_graphemes().layout.width_mode, width_mode); + } + } + } + mod hit_at { use super::*; diff --git a/promkit-widgets/src/text/config.rs b/promkit-widgets/src/text/config.rs index b1c857b7..dafb81f9 100644 --- a/promkit-widgets/src/text/config.rs +++ b/promkit-widgets/src/text/config.rs @@ -1,5 +1,16 @@ use promkit_core::crossterm::style::ContentStyle; +/// Defines how text is rendered when a line exceeds the available width. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum OverflowMode { + /// Truncates lines and appends an ellipsis character (…). + Truncate, + /// Wraps lines onto subsequent visual rows. + #[default] + Wrap, +} + #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(default))] #[derive(Clone, Default)] @@ -10,6 +21,7 @@ pub struct Config { )] pub style: Option, pub lines: Option, + pub overflow_mode: OverflowMode, } #[cfg(test)] @@ -18,13 +30,14 @@ mod tests { mod deserialize { use promkit_core::crossterm::style::{Attribute, Color}; - use super::super::Config; + use super::super::{Config, OverflowMode}; #[test] fn loads_all_fields_from_toml() { let input = r#" style = "fg=yellow,attr=bold" lines = 2 +overflow_mode = "Truncate" "#; let formatter: Config = toml::from_str(input).unwrap(); @@ -33,6 +46,14 @@ lines = 2 assert_eq!(style.foreground_color, Some(Color::Yellow)); assert!(style.attributes.has(Attribute::Bold)); assert_eq!(formatter.lines, Some(2)); + assert_eq!(formatter.overflow_mode, OverflowMode::Truncate); + } + + #[test] + fn uses_wrap_by_default() { + let formatter: Config = toml::from_str("").unwrap(); + + assert_eq!(formatter.overflow_mode, OverflowMode::Wrap); } } } diff --git a/promkit/Cargo.toml b/promkit/Cargo.toml index 861b5888..049124b4 100644 --- a/promkit/Cargo.toml +++ b/promkit/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "promkit" -version = "0.14.0" +version = "0.15.0" authors = ["ynqa "] edition = "2021" description = "A toolkit for building your own interactive command-line tools" @@ -26,7 +26,6 @@ all = [ "listbox", "serde", "spinner", - "status", "table", "text", "texteditor", @@ -56,7 +55,6 @@ listbox = ["widgets", "promkit-widgets/listbox"] prefixsearch = ["widgets", "promkit-widgets/prefixsearch"] serde = ["widgets", "promkit-widgets/serde"] spinner = ["widgets", "promkit-widgets/spinner"] -status = ["widgets", "promkit-widgets/status"] table = ["widgets", "promkit-widgets/table"] text = ["widgets", "promkit-widgets/text"] texteditor = ["widgets", "promkit-widgets/texteditor"] @@ -68,8 +66,8 @@ async-trait = { workspace = true, optional = true } bitflags = { workspace = true, optional = true } crossterm = { workspace = true, optional = true } futures = { workspace = true, optional = true } -promkit-core = { path = "../promkit-core", version = "=0.5.0", optional = true } -promkit-widgets = { path = "../promkit-widgets", version = "=0.7.0", default-features = false, optional = true } +promkit-core = { path = "../promkit-core", version = "=0.6.0", optional = true } +promkit-widgets = { path = "../promkit-widgets", version = "=0.8.0", default-features = false, optional = true } tokio = { workspace = true, optional = true } [package.metadata.docs.rs] diff --git a/tests/height-policy/Cargo.toml b/tests/height-policy/Cargo.toml new file mode 100644 index 00000000..607d66cc --- /dev/null +++ b/tests/height-policy/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "height-policy-tests" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +anyhow = { workspace = true } +futures = { workspace = true } +promkit-core = { path = "../../promkit-core" } +tokio = { workspace = true } + +[dev-dependencies] +termharness = "0.2.0" + +[[bin]] +name = "height-policy-fixture" +path = "src/height_policy_fixture.rs" diff --git a/tests/height-policy/src/height_policy_fixture.rs b/tests/height-policy/src/height_policy_fixture.rs new file mode 100644 index 00000000..1a8c165d --- /dev/null +++ b/tests/height-policy/src/height_policy_fixture.rs @@ -0,0 +1,155 @@ +use std::{env, io}; + +use futures::StreamExt; +use promkit_core::{ + crossterm::{ + cursor, + event::{Event, EventStream, KeyCode, KeyEventKind}, + execute, + terminal::{disable_raw_mode, enable_raw_mode}, + }, + grapheme::StyledGraphemes, + render::Renderer, + CreatedGraphemes, HeightPolicy, WidgetLayout, +}; + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Index { + First, + Second, + Third, + Fourth, +} + +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + execute!(io::stdout(), cursor::Show).ok(); + disable_raw_mode().ok(); + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + enable_raw_mode()?; + execute!(io::stdout(), cursor::Hide)?; + let _terminal_guard = TerminalGuard; + + let mode = env::args() + .nth(1) + .unwrap_or_else(|| "ordered-content".to_owned()); + let renderer = Renderer::try_new_with_graphemes(initial_content(&mode)?, true).await?; + let mut events = EventStream::new(); + + while let Some(event) = events.next().await { + match event? { + Event::Key(key) + if mode == "fair-content" + && key.kind == KeyEventKind::Press + && key.code == KeyCode::Tab => + { + renderer.update(short_fair_content()).render().await?; + } + Event::Key(key) + if key.kind == KeyEventKind::Press + && matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) => + { + break; + } + _ => {} + } + } + + Ok(()) +} + +fn initial_content(mode: &str) -> anyhow::Result> { + match mode { + "ordered-content" => Ok(ordered_content().into_iter().collect()), + "fair-fill" => Ok(fair_fill().into_iter().collect()), + "fair-content" => Ok(filled_fair_content().into_iter().collect()), + _ => Err(anyhow::anyhow!("unknown height policy fixture: {mode}")), + } +} + +fn ordered_content() -> [(Index, CreatedGraphemes); 3] { + [ + ( + Index::First, + pane( + "ordered-a-1\nordered-a-2\nordered-a-3\nordered-a-4\nordered-a-5", + HeightPolicy::OrderedContent, + ), + ), + ( + Index::Second, + pane( + "ordered-b-1\nordered-b-2\nordered-b-3\nordered-b-4\nordered-b-5", + HeightPolicy::OrderedContent, + ), + ), + ( + Index::Third, + pane( + "ordered-c-1\nordered-c-2\nordered-c-3\nordered-c-4\nordered-c-5", + HeightPolicy::OrderedContent, + ), + ), + ] +} + +fn fair_fill() -> [(Index, CreatedGraphemes); 4] { + [ + (Index::First, pane("header", HeightPolicy::OrderedContent)), + (Index::Second, pane("fair-a", HeightPolicy::FairFill)), + (Index::Third, pane("fair-b", HeightPolicy::FairFill)), + (Index::Fourth, pane("footer", HeightPolicy::OrderedContent)), + ] +} + +fn filled_fair_content() -> [(Index, CreatedGraphemes); 2] { + [ + ( + Index::First, + pane( + "upper-1\nupper-2\nupper-3\nupper-4\nupper-5\nupper-6", + HeightPolicy::FairContent, + ), + ), + ( + Index::Second, + pane( + "lower-1\nlower-2\nlower-3\nlower-4\nlower-5\nlower-6", + HeightPolicy::FairContent, + ), + ), + ] +} + +fn short_fair_content() -> [(Index, CreatedGraphemes); 2] { + [ + ( + Index::First, + pane("short-1\nshort-2", HeightPolicy::FairContent), + ), + ( + Index::Second, + pane( + "long-1\nlong-2\nlong-3\nlong-4\nlong-5\nlong-6", + HeightPolicy::FairContent, + ), + ), + ] +} + +fn pane(content: &str, height_policy: HeightPolicy) -> CreatedGraphemes { + CreatedGraphemes { + graphemes: StyledGraphemes::from(content), + layout: WidgetLayout { + height_policy, + ..Default::default() + }, + cursor: None, + } +} diff --git a/tests/height-policy/tests/scenarios/fair_content.th b/tests/height-policy/tests/scenarios/fair_content.th new file mode 100644 index 00000000..8b446f5b --- /dev/null +++ b/tests/height-policy/tests/scenarios/fair_content.th @@ -0,0 +1,30 @@ +Scenario "fair_content" +Command "CARGO_BIN_EXE_height-policy-fixture" +Arg "fair-content" +Terminal rows 8 cols 16 +Cursor row 1 col 1 + +Step "both contents use one half of the terminal" +Settle 300ms +Expect: + r00 |upper-1·········| + r01 |upper-2·········| + r02 |upper-3·········| + r03 |upper-4·········| + r04 |lower-1·········| + r05 |lower-2·········| + r06 |lower-3·········| + r07 |lower-4·········| + +Step "short upper content packs the lower content upward" +Input tab +Settle 100ms +Expect: + r00 |short-1·········| + r01 |short-2·········| + r02 |long-1··········| + r03 |long-2··········| + r04 |long-3··········| + r05 |long-4··········| + r06 |················| + r07 |················| diff --git a/tests/height-policy/tests/scenarios/fair_fill.th b/tests/height-policy/tests/scenarios/fair_fill.th new file mode 100644 index 00000000..c7b7efb9 --- /dev/null +++ b/tests/height-policy/tests/scenarios/fair_fill.th @@ -0,0 +1,17 @@ +Scenario "fair_fill" +Command "CARGO_BIN_EXE_height-policy-fixture" +Arg "fair-fill" +Terminal rows 8 cols 16 +Cursor row 1 col 1 + +Step "fill panes share and preserve the remaining height" +Settle 300ms +Expect: + r00 |header··········| + r01 |fair-a··········| + r02 |················| + r03 |················| + r04 |fair-b··········| + r05 |················| + r06 |················| + r07 |footer··········| diff --git a/tests/height-policy/tests/scenarios/ordered_content.th b/tests/height-policy/tests/scenarios/ordered_content.th new file mode 100644 index 00000000..364e37d3 --- /dev/null +++ b/tests/height-policy/tests/scenarios/ordered_content.th @@ -0,0 +1,17 @@ +Scenario "ordered_content" +Command "CARGO_BIN_EXE_height-policy-fixture" +Arg "ordered-content" +Terminal rows 8 cols 16 +Cursor row 1 col 1 + +Step "content receives height from top to bottom" +Settle 300ms +Expect: + r00 |ordered-a-1·····| + r01 |ordered-a-2·····| + r02 |ordered-a-3·····| + r03 |ordered-a-4·····| + r04 |ordered-a-5·····| + r05 |ordered-b-1·····| + r06 |ordered-b-2·····| + r07 |ordered-c-1·····| diff --git a/tests/height-policy/tests/terminal_scenarios.rs b/tests/height-policy/tests/terminal_scenarios.rs new file mode 100644 index 00000000..b7994f55 --- /dev/null +++ b/tests/height-policy/tests/terminal_scenarios.rs @@ -0,0 +1,19 @@ +use termharness::{error::Result, scenario}; + +#[test] +fn ordered_content_allocates_height_in_order() -> Result<()> { + scenario::run_document(include_str!("scenarios/ordered_content.th"))?; + Ok(()) +} + +#[test] +fn fair_fill_shares_and_preserves_height() -> Result<()> { + scenario::run_document(include_str!("scenarios/fair_fill.th"))?; + Ok(()) +} + +#[test] +fn fair_content_caps_each_share_and_packs_short_content() -> Result<()> { + scenario::run_document(include_str!("scenarios/fair_content.th"))?; + Ok(()) +}