From 975a55a417e13a2e457dff19a85fb504d7c68737 Mon Sep 17 00:00:00 2001 From: ynqa Date: Tue, 28 Jul 2026 17:36:27 +0900 Subject: [PATCH 01/11] Bump promkit crates for v0.15.0 --- Concept.md | 2 +- README.md | 2 +- promkit-core/Cargo.toml | 2 +- promkit-widgets/Cargo.toml | 4 ++-- promkit-widgets/README.md | 4 ++-- promkit/Cargo.toml | 6 +++--- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Concept.md b/Concept.md index fa69098f..28488b9e 100644 --- a/Concept.md +++ b/Concept.md @@ -145,7 +145,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", diff --git a/README.md b/README.md index 0d382705..638b47f8 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 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-widgets/Cargo.toml b/promkit-widgets/Cargo.toml index dc01fecb..ef95ad7c 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" @@ -26,7 +26,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..66cb684c 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 diff --git a/promkit/Cargo.toml b/promkit/Cargo.toml index 861b5888..a8936eaa 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" @@ -68,8 +68,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] From ed02346b19d58af9846ef1961d61a7cc2e414c95 Mon Sep 17 00:00:00 2001 From: ynqa Date: Wed, 5 Aug 2026 13:30:56 +0900 Subject: [PATCH 02/11] Remove status widget --- README.md | 2 +- promkit-widgets/Cargo.toml | 3 +- promkit-widgets/README.md | 1 - promkit-widgets/src/lib.rs | 4 --- promkit-widgets/src/status.rs | 61 ----------------------------------- promkit/Cargo.toml | 2 -- 6 files changed, 2 insertions(+), 71 deletions(-) delete mode 100644 promkit-widgets/src/status.rs diff --git a/README.md b/README.md index 638b47f8..a84183ea 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ promkit = { version = "0.15.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/promkit-widgets/Cargo.toml b/promkit-widgets/Cargo.toml index ef95ad7c..5689a7c8 100644 --- a/promkit-widgets/Cargo.toml +++ b/promkit-widgets/Cargo.toml @@ -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 = [] diff --git a/promkit-widgets/README.md b/promkit-widgets/README.md index 66cb684c..d1e2ffb1 100644 --- a/promkit-widgets/README.md +++ b/promkit-widgets/README.md @@ -41,7 +41,6 @@ promkit-widgets = { version = "0.8", 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/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/Cargo.toml b/promkit/Cargo.toml index a8936eaa..049124b4 100644 --- a/promkit/Cargo.toml +++ b/promkit/Cargo.toml @@ -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"] From 0167bf4bcc7f9de07ba8e41ec40d9ecd96573899 Mon Sep 17 00:00:00 2001 From: ynqa Date: Wed, 5 Aug 2026 13:37:14 +0900 Subject: [PATCH 03/11] Document v0.14.0 changes --- CHANGELOG.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) 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 From 625cc795a2af1463fe5f2ccd392803dad4540881 Mon Sep 17 00:00:00 2001 From: ynqa Date: Wed, 5 Aug 2026 14:58:06 +0900 Subject: [PATCH 04/11] Add text overflow configuration --- examples/readline/src/lib.rs | 1 + promkit-widgets/src/text.rs | 29 +++++++++++++++++++++++++++-- promkit-widgets/src/text/config.rs | 23 ++++++++++++++++++++++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/examples/readline/src/lib.rs b/examples/readline/src/lib.rs index 682625c4..21440f7c 100644 --- a/examples/readline/src/lib.rs +++ b/examples/readline/src/lib.rs @@ -114,6 +114,7 @@ impl Default for Readline { ..Default::default() }), lines: None, + ..Default::default() }, }, } diff --git a/promkit-widgets/src/text.rs b/promkit-widgets/src/text.rs index aeac507f..678bdd9c 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,7 +44,10 @@ impl Widget for State { graphemes: StyledGraphemes::from_lines(lines), layout: WidgetLayout { max_height: self.config.lines, - ..Default::default() + width_mode: match self.config.overflow_mode { + config::OverflowMode::Truncate => WidthMode::Truncate, + config::OverflowMode::Wrap => WidthMode::Wrap, + }, }, cursor: self .text @@ -85,6 +88,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); } } } From b6d709699f5e81de3628e1c898c6252a8e201d8c Mon Sep 17 00:00:00 2001 From: ynqa Date: Fri, 7 Aug 2026 10:49:18 +0900 Subject: [PATCH 05/11] Rename PrefixSearch query setter --- examples/readline/src/evaluate.rs | 2 +- promkit-widgets/src/prefix_search.rs | 2 +- .../src/prefix_search/prefix_search.rs | 26 +++++++++---------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/examples/readline/src/evaluate.rs b/examples/readline/src/evaluate.rs index 251435bb..ff81658e 100644 --- a/examples/readline/src/evaluate.rs +++ b/examples/readline/src/evaluate.rs @@ -131,7 +131,7 @@ 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) { + if ctx.suggestions.prefix_search.set_query(text) { ctx.focus = Focus::Suggestion; } else { dismiss_suggestions(ctx); diff --git a/promkit-widgets/src/prefix_search.rs b/promkit-widgets/src/prefix_search.rs index ec33fba6..ea5d91b0 100644 --- a/promkit-widgets/src/prefix_search.rs +++ b/promkit-widgets/src/prefix_search.rs @@ -98,7 +98,7 @@ mod tests { let mut prefix_search: PrefixSearch = ["apple", "applet", "application", "banana"] .into_iter() .collect(); - prefix_search.search("app"); + prefix_search.set_query("app"); let mut state = State { prefix_search, config: Config { diff --git a/promkit-widgets/src/prefix_search/prefix_search.rs b/promkit-widgets/src/prefix_search/prefix_search.rs index fba29407..198194ab 100644 --- a/promkit-widgets/src/prefix_search/prefix_search.rs +++ b/promkit-widgets/src/prefix_search/prefix_search.rs @@ -30,10 +30,10 @@ impl FromIterator for PrefixSearch { } impl PrefixSearch { - /// Updates the active query and selects the first matching candidate. + /// Sets 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 { + pub fn set_query(&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); @@ -118,14 +118,14 @@ mod tests { .collect() } - mod search { + mod set_query { use super::*; #[test] fn filters_the_trie_and_selects_the_first_match() { let mut prefix_search = prefix_search(); - assert!(prefix_search.search("app")); + assert!(prefix_search.set_query("app")); assert_eq!( prefix_search.candidates().collect::>(), vec!["apple", "applet", "application"] @@ -133,7 +133,7 @@ mod tests { assert_eq!(prefix_search.selected(), Some(0)); assert_eq!(prefix_search.get(), Some("apple")); - assert!(prefix_search.search("ban")); + assert!(prefix_search.set_query("ban")); assert_eq!( prefix_search.candidates().collect::>(), vec!["banana"] @@ -146,8 +146,8 @@ mod tests { fn missing_match_clears_the_selection() { let mut prefix_search = prefix_search(); - assert!(prefix_search.search("app")); - assert!(!prefix_search.search("orange")); + assert!(prefix_search.set_query("app")); + assert!(!prefix_search.set_query("orange")); assert!(prefix_search.is_empty()); assert_eq!(prefix_search.selected(), None); assert_eq!(prefix_search.get(), None); @@ -160,7 +160,7 @@ mod tests { #[test] fn stops_at_the_first_match() { let mut prefix_search = prefix_search(); - prefix_search.search("app"); + prefix_search.set_query("app"); assert!(!prefix_search.backward()); assert_eq!(prefix_search.get(), Some("apple")); @@ -173,7 +173,7 @@ mod tests { #[test] fn stops_at_the_last_match() { let mut prefix_search = prefix_search(); - prefix_search.search("app"); + prefix_search.set_query("app"); assert!(prefix_search.forward()); assert_eq!(prefix_search.get(), Some("applet")); @@ -189,7 +189,7 @@ mod tests { #[test] fn rejects_an_out_of_bounds_index() { let mut prefix_search = prefix_search(); - prefix_search.search("app"); + prefix_search.set_query("app"); prefix_search.move_to(2); assert!(!prefix_search.move_to(3)); @@ -199,7 +199,7 @@ mod tests { #[test] fn selects_the_given_match() { let mut prefix_search = prefix_search(); - prefix_search.search("app"); + prefix_search.set_query("app"); prefix_search.move_to(2); assert!(prefix_search.move_to(0)); @@ -213,13 +213,13 @@ mod tests { #[test] fn hides_matches_without_removing_candidates() { let mut prefix_search = prefix_search(); - prefix_search.search("app"); + prefix_search.set_query("app"); prefix_search.clear(); assert!(prefix_search.is_empty()); assert_eq!(prefix_search.selected(), None); - assert!(prefix_search.search("app")); + assert!(prefix_search.set_query("app")); assert_eq!(prefix_search.get(), Some("apple")); } } From f9ec28c8e98b57929c695ca87ec76117991435a4 Mon Sep 17 00:00:00 2001 From: ynqa Date: Fri, 7 Aug 2026 11:20:18 +0900 Subject: [PATCH 06/11] Return snapshots from prefix search queries --- examples/readline/src/evaluate.rs | 13 +- examples/readline/src/lib.rs | 4 +- examples/readline/src/readline.rs | 3 +- promkit-widgets/src/prefix_search.rs | 32 +-- .../src/prefix_search/prefix_search.rs | 251 ++++++++++-------- 5 files changed, 163 insertions(+), 140 deletions(-) diff --git a/examples/readline/src/evaluate.rs b/examples/readline/src/evaluate.rs index ff81658e..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.set_query(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 21440f7c..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 { 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-widgets/src/prefix_search.rs b/promkit-widgets/src/prefix_search.rs index ea5d91b0..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.set_query("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 198194ab..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 { - /// Sets the active query and selects the first matching candidate. - /// - /// Returns `true` when at least one candidate matches. - pub fn set_query(&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 set_query { + mod prefix_search { use super::*; - #[test] - fn filters_the_trie_and_selects_the_first_match() { - let mut prefix_search = prefix_search(); - - assert!(prefix_search.set_query("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.set_query("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.set_query("app")); - assert!(!prefix_search.set_query("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.set_query("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.set_query("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.set_query("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.set_query("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.set_query("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.set_query("app")); - assert_eq!(prefix_search.get(), Some("apple")); + assert!(result.is_empty()); + assert_eq!(result.selected(), None); + assert_eq!(result.get(), None); + } } } } From e78180326516e1deb30ef9b8acf28d2e8616427e Mon Sep 17 00:00:00 2001 From: ynqa Date: Fri, 14 Aug 2026 11:38:02 +0900 Subject: [PATCH 07/11] Add content and fill height allocation --- Concept.md | 6 + promkit-core/benches/renderer_layout.rs | 1 + promkit-core/src/lib.rs | 4 +- promkit-core/src/render.rs | 7 +- promkit-core/src/render/layout.rs | 152 +++++++++++++++++++++--- promkit-core/src/widget.rs | 18 ++- promkit-widgets/src/structured/json.rs | 1 + promkit-widgets/src/structured/yaml.rs | 1 + promkit-widgets/src/table.rs | 2 + promkit-widgets/src/text.rs | 1 + 10 files changed, 172 insertions(+), 21 deletions(-) diff --git a/Concept.md b/Concept.md index 28488b9e..bc24c2ed 100644 --- a/Concept.md +++ b/Concept.md @@ -28,6 +28,12 @@ The resulting development loop is: 4. Application-specific orchestration remains in the application, while examples document useful compositions. +The renderer supports two vertical sizing modes. `Height::Content` uses the +wrapped content height, optionally capped by `max_height`. `Height::Fill` +shares the height remaining after content-sized items equally with other fill +items. Keeping these modes 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 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..b759415d 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, Height, ScreenPosition, ViewportChange, VisualPosition, + Widget, WidgetLayout, WidgetPosition, WidgetViewport, WidthMode, }; diff --git a/promkit-core/src/render.rs b/promkit-core/src/render.rs index 0052484b..213abb08 100644 --- a/promkit-core/src/render.rs +++ b/promkit-core/src/render.rs @@ -6,9 +6,10 @@ //! 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. +//! Content-sized 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::Height::Fill`]. 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..39d87502 100644 --- a/promkit-core/src/render/layout.rs +++ b/promkit-core/src/render/layout.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use crate::{ grapheme::StyledGraphemes, widget::{ - ContentPosition, CreatedGraphemes, ScreenPosition, VisualPosition, WidgetViewport, + ContentPosition, CreatedGraphemes, Height, ScreenPosition, VisualPosition, WidgetViewport, WidthMode, }, }; @@ -81,7 +81,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() } @@ -148,19 +149,29 @@ impl RendererLayout { 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(); - - 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); - + let desired_heights = laid_out + .iter() + .map(|(_, layout, _, rows)| match layout.height { + Height::Content => layout.max_height.unwrap_or(rows.len()).min(rows.len()), + Height::Fill => layout.max_height.unwrap_or(terminal_height as usize), + }) + .map(|height| height.max(1)) + .collect::>(); + let heights = allocate_heights( + &desired_heights, + &laid_out + .iter() + .map(|(_, layout, _, _)| layout.height) + .collect::>(), + terminal_height as usize, + ); + let mut entries = Vec::with_capacity(pane_count); + + for ((index, layout, cursor, mut rows), height) in laid_out.into_iter().zip(heights) { + if layout.height == Height::Fill && rows.len() < height { + pad_rows_to_height(&mut rows, height); + } let mut viewport = WidgetViewport { height: height as u16, content_row: self.viewport_rows.get(&index).copied().unwrap_or_default(), @@ -197,6 +208,57 @@ impl RendererLayout { } } +fn allocate_heights(desired: &[usize], sizing: &[Height], available: usize) -> Vec { + debug_assert_eq!(desired.len(), sizing.len()); + debug_assert!(desired.len() <= available); + + let mut heights = vec![1usize; desired.len()]; + let mut remaining = available.saturating_sub(heights.len()); + + for (index, (&desired, &sizing)) in desired.iter().zip(sizing).enumerate() { + if sizing == Height::Fill { + continue; + } + let extra = desired.saturating_sub(1).min(remaining); + heights[index] = heights[index].saturating_add(extra); + remaining = remaining.saturating_sub(extra); + } + + while remaining > 0 { + let mut distributed = false; + for (index, sizing) in sizing.iter().enumerate() { + if *sizing == Height::Fill && heights[index] < desired[index] { + heights[index] += 1; + remaining -= 1; + distributed = true; + if remaining == 0 { + break; + } + } + } + if !distributed { + break; + } + } + + heights +} + +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, @@ -376,6 +438,46 @@ mod tests { use super::*; use crate::widget::WidgetLayout; + mod allocate_heights { + use super::*; + + #[test] + fn preserves_content_allocation_in_key_order() { + assert_eq!( + allocate_heights( + &[10, 10, 10], + &[Height::Content, Height::Content, Height::Content], + 8, + ), + [6, 1, 1] + ); + } + + #[test] + fn shares_height_equally_between_fill_entries() { + assert_eq!( + allocate_heights( + &[2, 10, 10], + &[Height::Content, Height::Fill, Height::Fill], + 10, + ), + [2, 4, 4] + ); + } + + #[test] + fn reallocates_height_after_a_fill_entry_reaches_its_limit() { + assert_eq!( + allocate_heights( + &[2, 20, 3], + &[Height::Content, Height::Fill, Height::Fill], + 12, + ), + [2, 7, 3] + ); + } + } + mod visual_position { use super::*; @@ -509,6 +611,28 @@ 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: Height::Fill, + ..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] + ); + } } } } diff --git a/promkit-core/src/widget.rs b/promkit-core/src/widget.rs index 7d17d4f3..e01023f4 100644 --- a/promkit-core/src/widget.rs +++ b/promkit-core/src/widget.rs @@ -67,14 +67,28 @@ pub enum WidthMode { Truncate, } +/// Vertical sizing behavior applied by the renderer. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Height { + /// Use the content height, subject to [`WidgetLayout::max_height`]. + #[default] + Content, + /// Share the height left after content-sized widgets with other fill widgets, + /// subject to [`WidgetLayout::max_height`]. + Fill, +} + /// 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. [`Height::Content`] uses that content-derived height, +/// while [`Height::Fill`] shares the remaining terminal height equally with +/// other fill widgets. `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: Height, pub max_height: Option, pub width_mode: WidthMode, } 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 678bdd9c..4745d7c7 100644 --- a/promkit-widgets/src/text.rs +++ b/promkit-widgets/src/text.rs @@ -48,6 +48,7 @@ impl Widget for State { config::OverflowMode::Truncate => WidthMode::Truncate, config::OverflowMode::Wrap => WidthMode::Wrap, }, + ..Default::default() }, cursor: self .text From 243df0bf83c2ad645968823e1ad60712980716b0 Mon Sep 17 00:00:00 2001 From: ynqa Date: Fri, 14 Aug 2026 11:53:45 +0900 Subject: [PATCH 08/11] Rename height sizing to height policy --- Concept.md | 10 +++---- promkit-core/src/lib.rs | 4 +-- promkit-core/src/render.rs | 4 +-- promkit-core/src/render/layout.rs | 46 +++++++++++++++++++------------ promkit-core/src/widget.rs | 12 ++++---- 5 files changed, 44 insertions(+), 32 deletions(-) diff --git a/Concept.md b/Concept.md index bc24c2ed..a11b1d47 100644 --- a/Concept.md +++ b/Concept.md @@ -28,11 +28,11 @@ The resulting development loop is: 4. Application-specific orchestration remains in the application, while examples document useful compositions. -The renderer supports two vertical sizing modes. `Height::Content` uses the -wrapped content height, optionally capped by `max_height`. `Height::Fill` -shares the height remaining after content-sized items equally with other fill -items. Keeping these modes explicit avoids making individual widgets reproduce -container-level allocation policy. +The renderer supports two vertical sizing policies. `HeightPolicy::Content` +uses the wrapped content height, optionally capped by `max_height`. +`HeightPolicy::Fill` shares the height remaining after content-sized items +equally with other fill items. 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 diff --git a/promkit-core/src/lib.rs b/promkit-core/src/lib.rs index b759415d..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, Height, 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 213abb08..cac301cc 100644 --- a/promkit-core/src/render.rs +++ b/promkit-core/src/render.rs @@ -8,8 +8,8 @@ //! Empty items and items with `max_height == Some(0)` do not occupy space. //! Content-sized 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::Height::Fill`]. A terminal that cannot provide one row -//! per non-empty item produces an error. +//! items using [`crate::HeightPolicy::Fill`]. 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 39d87502..b3318d2b 100644 --- a/promkit-core/src/render/layout.rs +++ b/promkit-core/src/render/layout.rs @@ -3,8 +3,8 @@ use std::collections::BTreeMap; use crate::{ grapheme::StyledGraphemes, widget::{ - ContentPosition, CreatedGraphemes, Height, ScreenPosition, VisualPosition, WidgetViewport, - WidthMode, + ContentPosition, CreatedGraphemes, HeightPolicy, ScreenPosition, VisualPosition, + WidgetViewport, WidthMode, }, }; @@ -152,9 +152,9 @@ impl RendererLayout { let pane_count = laid_out.len(); let desired_heights = laid_out .iter() - .map(|(_, layout, _, rows)| match layout.height { - Height::Content => layout.max_height.unwrap_or(rows.len()).min(rows.len()), - Height::Fill => layout.max_height.unwrap_or(terminal_height as usize), + .map(|(_, layout, _, rows)| match layout.height_policy { + HeightPolicy::Content => layout.max_height.unwrap_or(rows.len()).min(rows.len()), + HeightPolicy::Fill => layout.max_height.unwrap_or(terminal_height as usize), }) .map(|height| height.max(1)) .collect::>(); @@ -162,14 +162,14 @@ impl RendererLayout { &desired_heights, &laid_out .iter() - .map(|(_, layout, _, _)| layout.height) + .map(|(_, layout, _, _)| layout.height_policy) .collect::>(), terminal_height as usize, ); let mut entries = Vec::with_capacity(pane_count); for ((index, layout, cursor, mut rows), height) in laid_out.into_iter().zip(heights) { - if layout.height == Height::Fill && rows.len() < height { + if layout.height_policy == HeightPolicy::Fill && rows.len() < height { pad_rows_to_height(&mut rows, height); } let mut viewport = WidgetViewport { @@ -208,15 +208,15 @@ impl RendererLayout { } } -fn allocate_heights(desired: &[usize], sizing: &[Height], available: usize) -> Vec { - debug_assert_eq!(desired.len(), sizing.len()); +fn allocate_heights(desired: &[usize], policies: &[HeightPolicy], available: usize) -> Vec { + debug_assert_eq!(desired.len(), policies.len()); debug_assert!(desired.len() <= available); let mut heights = vec![1usize; desired.len()]; let mut remaining = available.saturating_sub(heights.len()); - for (index, (&desired, &sizing)) in desired.iter().zip(sizing).enumerate() { - if sizing == Height::Fill { + for (index, (&desired, &policy)) in desired.iter().zip(policies).enumerate() { + if policy == HeightPolicy::Fill { continue; } let extra = desired.saturating_sub(1).min(remaining); @@ -226,8 +226,8 @@ fn allocate_heights(desired: &[usize], sizing: &[Height], available: usize) -> V while remaining > 0 { let mut distributed = false; - for (index, sizing) in sizing.iter().enumerate() { - if *sizing == Height::Fill && heights[index] < desired[index] { + for (index, policy) in policies.iter().enumerate() { + if *policy == HeightPolicy::Fill && heights[index] < desired[index] { heights[index] += 1; remaining -= 1; distributed = true; @@ -446,7 +446,11 @@ mod tests { assert_eq!( allocate_heights( &[10, 10, 10], - &[Height::Content, Height::Content, Height::Content], + &[ + HeightPolicy::Content, + HeightPolicy::Content, + HeightPolicy::Content, + ], 8, ), [6, 1, 1] @@ -458,7 +462,11 @@ mod tests { assert_eq!( allocate_heights( &[2, 10, 10], - &[Height::Content, Height::Fill, Height::Fill], + &[ + HeightPolicy::Content, + HeightPolicy::Fill, + HeightPolicy::Fill, + ], 10, ), [2, 4, 4] @@ -470,7 +478,11 @@ mod tests { assert_eq!( allocate_heights( &[2, 20, 3], - &[Height::Content, Height::Fill, Height::Fill], + &[ + HeightPolicy::Content, + HeightPolicy::Fill, + HeightPolicy::Fill, + ], 12, ), [2, 7, 3] @@ -617,7 +629,7 @@ mod tests { let created = || CreatedGraphemes { graphemes: StyledGraphemes::from("content"), layout: WidgetLayout { - height: Height::Fill, + height_policy: HeightPolicy::Fill, ..Default::default() }, cursor: None, diff --git a/promkit-core/src/widget.rs b/promkit-core/src/widget.rs index e01023f4..67ca824a 100644 --- a/promkit-core/src/widget.rs +++ b/promkit-core/src/widget.rs @@ -69,7 +69,7 @@ pub enum WidthMode { /// Vertical sizing behavior applied by the renderer. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum Height { +pub enum HeightPolicy { /// Use the content height, subject to [`WidgetLayout::max_height`]. #[default] Content, @@ -82,13 +82,13 @@ pub enum Height { /// /// `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. [`Height::Content`] uses that content-derived height, -/// while [`Height::Fill`] shares the remaining terminal height equally with -/// other fill widgets. `width_mode` controls whether each logical row wraps or -/// is truncated with an ellipsis. +/// non-empty widgets. [`HeightPolicy::Content`] uses that content-derived +/// height, while [`HeightPolicy::Fill`] shares the remaining terminal height +/// equally with other fill widgets. `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: Height, + pub height_policy: HeightPolicy, pub max_height: Option, pub width_mode: WidthMode, } From 77b4b9ee89c1aa7ab551cea2d719e00f528a80a9 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sat, 15 Aug 2026 19:42:13 +0900 Subject: [PATCH 09/11] Rename renderer height policies --- Concept.md | 18 +-- promkit-core/src/render.rs | 6 +- promkit-core/src/render/layout.rs | 63 ++++++++--- promkit-core/src/widget.rs | 25 +++-- tests/height-policy/Cargo.toml | 18 +++ .../src/height_policy_fixture.rs | 104 ++++++++++++++++++ .../height_policies_allocate_terminal_rows.th | 29 +++++ .../height-policy/tests/terminal_scenarios.rs | 9 ++ 8 files changed, 236 insertions(+), 36 deletions(-) create mode 100644 tests/height-policy/Cargo.toml create mode 100644 tests/height-policy/src/height_policy_fixture.rs create mode 100644 tests/height-policy/tests/scenarios/height_policies_allocate_terminal_rows.th create mode 100644 tests/height-policy/tests/terminal_scenarios.rs diff --git a/Concept.md b/Concept.md index a11b1d47..3520fef7 100644 --- a/Concept.md +++ b/Concept.md @@ -28,11 +28,14 @@ The resulting development loop is: 4. Application-specific orchestration remains in the application, while examples document useful compositions. -The renderer supports two vertical sizing policies. `HeightPolicy::Content` -uses the wrapped content height, optionally capped by `max_height`. -`HeightPolicy::Fill` shares the height remaining after content-sized items -equally with other fill items. Keeping these policies explicit avoids making -individual widgets reproduce container-level allocation policy. +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` is +reserved for fair allocation bounded by content height, but is not implemented +yet. 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 @@ -177,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/promkit-core/src/render.rs b/promkit-core/src/render.rs index cac301cc..9d598267 100644 --- a/promkit-core/src/render.rs +++ b/promkit-core/src/render.rs @@ -8,8 +8,10 @@ //! Empty items and items with `max_height == Some(0)` do not occupy space. //! Content-sized 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::Fill`]. A terminal that cannot provide one -//! row per non-empty item produces an error. +//! items using [`crate::HeightPolicy::FairFill`]. +//! [`crate::HeightPolicy::FairContent`] is reserved and currently produces an +//! error. A terminal that cannot provide one row per non-empty item also +//! 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 b3318d2b..c5fd8351 100644 --- a/promkit-core/src/render/layout.rs +++ b/promkit-core/src/render/layout.rs @@ -153,11 +153,16 @@ impl RendererLayout { let desired_heights = laid_out .iter() .map(|(_, layout, _, rows)| match layout.height_policy { - HeightPolicy::Content => layout.max_height.unwrap_or(rows.len()).min(rows.len()), - HeightPolicy::Fill => layout.max_height.unwrap_or(terminal_height as usize), + HeightPolicy::OrderedContent => { + Ok(layout.max_height.unwrap_or(rows.len()).min(rows.len())) + } + HeightPolicy::FairFill => Ok(layout.max_height.unwrap_or(terminal_height as usize)), + HeightPolicy::FairContent => Err(anyhow::anyhow!( + "HeightPolicy::FairContent is not implemented" + )), }) - .map(|height| height.max(1)) - .collect::>(); + .map(|height| height.map(|height| height.max(1))) + .collect::>>()?; let heights = allocate_heights( &desired_heights, &laid_out @@ -169,7 +174,7 @@ impl RendererLayout { let mut entries = Vec::with_capacity(pane_count); for ((index, layout, cursor, mut rows), height) in laid_out.into_iter().zip(heights) { - if layout.height_policy == HeightPolicy::Fill && rows.len() < height { + if layout.height_policy == HeightPolicy::FairFill && rows.len() < height { pad_rows_to_height(&mut rows, height); } let mut viewport = WidgetViewport { @@ -216,8 +221,10 @@ fn allocate_heights(desired: &[usize], policies: &[HeightPolicy], available: usi let mut remaining = available.saturating_sub(heights.len()); for (index, (&desired, &policy)) in desired.iter().zip(policies).enumerate() { - if policy == HeightPolicy::Fill { - continue; + match policy { + HeightPolicy::OrderedContent => {} + HeightPolicy::FairFill => continue, + HeightPolicy::FairContent => unreachable!("rejected before height allocation"), } let extra = desired.saturating_sub(1).min(remaining); heights[index] = heights[index].saturating_add(extra); @@ -227,7 +234,7 @@ fn allocate_heights(desired: &[usize], policies: &[HeightPolicy], available: usi while remaining > 0 { let mut distributed = false; for (index, policy) in policies.iter().enumerate() { - if *policy == HeightPolicy::Fill && heights[index] < desired[index] { + if *policy == HeightPolicy::FairFill && heights[index] < desired[index] { heights[index] += 1; remaining -= 1; distributed = true; @@ -447,9 +454,9 @@ mod tests { allocate_heights( &[10, 10, 10], &[ - HeightPolicy::Content, - HeightPolicy::Content, - HeightPolicy::Content, + HeightPolicy::OrderedContent, + HeightPolicy::OrderedContent, + HeightPolicy::OrderedContent, ], 8, ), @@ -463,9 +470,9 @@ mod tests { allocate_heights( &[2, 10, 10], &[ - HeightPolicy::Content, - HeightPolicy::Fill, - HeightPolicy::Fill, + HeightPolicy::OrderedContent, + HeightPolicy::FairFill, + HeightPolicy::FairFill, ], 10, ), @@ -479,9 +486,9 @@ mod tests { allocate_heights( &[2, 20, 3], &[ - HeightPolicy::Content, - HeightPolicy::Fill, - HeightPolicy::Fill, + HeightPolicy::OrderedContent, + HeightPolicy::FairFill, + HeightPolicy::FairFill, ], 12, ), @@ -629,7 +636,7 @@ mod tests { let created = || CreatedGraphemes { graphemes: StyledGraphemes::from("content"), layout: WidgetLayout { - height_policy: HeightPolicy::Fill, + height_policy: HeightPolicy::FairFill, ..Default::default() }, cursor: None, @@ -645,6 +652,26 @@ mod tests { [3, 3] ); } + + #[test] + fn rejects_unimplemented_fair_content() { + let created = CreatedGraphemes { + graphemes: StyledGraphemes::from("content"), + layout: WidgetLayout { + height_policy: HeightPolicy::FairContent, + ..Default::default() + }, + cursor: None, + }; + let mut layout = RendererLayout::default(); + + let error = layout.layout([(0, created)], 80, 24).unwrap_err(); + + assert_eq!( + error.to_string(), + "HeightPolicy::FairContent is not implemented" + ); + } } } } diff --git a/promkit-core/src/widget.rs b/promkit-core/src/widget.rs index 67ca824a..17cc8557 100644 --- a/promkit-core/src/widget.rs +++ b/promkit-core/src/widget.rs @@ -70,22 +70,29 @@ pub enum WidthMode { /// Vertical sizing behavior applied by the renderer. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum HeightPolicy { - /// Use the content height, subject to [`WidgetLayout::max_height`]. + /// Allocate content height in widget order, subject to + /// [`WidgetLayout::max_height`] and the remaining terminal height. #[default] - Content, - /// Share the height left after content-sized widgets with other fill widgets, - /// subject to [`WidgetLayout::max_height`]. - Fill, + OrderedContent, + /// Share the remaining height fairly, but do not exceed the content height. + /// + /// This policy is reserved for a future implementation. The renderer + /// currently returns an error for a non-empty widget that uses it. + 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. [`HeightPolicy::Content`] uses that content-derived -/// height, while [`HeightPolicy::Fill`] shares the remaining terminal height -/// equally with other fill 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 fill widgets. [`HeightPolicy::FairContent`] +/// is reserved and not yet implemented. `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, 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..020e3cf0 --- /dev/null +++ b/tests/height-policy/src/height_policy_fixture.rs @@ -0,0 +1,104 @@ +use std::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 renderer = Renderer::try_new_with_graphemes(ordered_content(), true).await?; + let mut events = EventStream::new(); + + while let Some(event) = events.next().await { + match event? { + Event::Key(key) if key.kind == KeyEventKind::Press && key.code == KeyCode::Tab => { + renderer.update(fair_fill()).render().await?; + } + Event::Key(key) + if key.kind == KeyEventKind::Press + && matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) => + { + break; + } + _ => {} + } + } + + Ok(()) +} + +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 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/height_policies_allocate_terminal_rows.th b/tests/height-policy/tests/scenarios/height_policies_allocate_terminal_rows.th new file mode 100644 index 00000000..25ef1e07 --- /dev/null +++ b/tests/height-policy/tests/scenarios/height_policies_allocate_terminal_rows.th @@ -0,0 +1,29 @@ +Scenario "height_policies_allocate_terminal_rows" +Command "CARGO_BIN_EXE_height-policy-fixture" +Terminal rows 8 cols 16 +Cursor row 8 col 1 + +Step "ordered 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·····| + +Step "fair fill shares and preserves the remaining height" +Input tab +Settle 100ms +Expect: + r00 |header··········| + r01 |fair-a··········| + r02 |················| + r03 |················| + r04 |fair-b··········| + r05 |················| + r06 |················| + r07 |footer··········| diff --git a/tests/height-policy/tests/terminal_scenarios.rs b/tests/height-policy/tests/terminal_scenarios.rs new file mode 100644 index 00000000..ad434d2b --- /dev/null +++ b/tests/height-policy/tests/terminal_scenarios.rs @@ -0,0 +1,9 @@ +use termharness::{error::Result, scenario}; + +#[test] +fn height_policies_allocate_terminal_rows() -> Result<()> { + scenario::run_document(include_str!( + "scenarios/height_policies_allocate_terminal_rows.th" + ))?; + Ok(()) +} From 6353d546fbfc1e3b433397e825d3c4cb9671f6a5 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sat, 15 Aug 2026 21:24:24 +0900 Subject: [PATCH 10/11] Cap fair content at its equal share --- Concept.md | 8 +- promkit-core/src/render.rs | 11 +-- promkit-core/src/render/layout.rs | 82 +++++++++++++++---- promkit-core/src/widget.rs | 13 ++- .../src/height_policy_fixture.rs | 59 ++++++++++++- .../tests/scenarios/fair_content.th | 30 +++++++ .../tests/scenarios/fair_fill.th | 17 ++++ .../height_policies_allocate_terminal_rows.th | 29 ------- .../tests/scenarios/ordered_content.th | 17 ++++ .../height-policy/tests/terminal_scenarios.rs | 18 +++- 10 files changed, 213 insertions(+), 71 deletions(-) create mode 100644 tests/height-policy/tests/scenarios/fair_content.th create mode 100644 tests/height-policy/tests/scenarios/fair_fill.th delete mode 100644 tests/height-policy/tests/scenarios/height_policies_allocate_terminal_rows.th create mode 100644 tests/height-policy/tests/scenarios/ordered_content.th diff --git a/Concept.md b/Concept.md index 3520fef7..888a9c40 100644 --- a/Concept.md +++ b/Concept.md @@ -32,10 +32,10 @@ The renderer defines three vertical sizing policies. `HeightPolicy::OrderedConte 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` is -reserved for fair allocation bounded by content height, but is not implemented -yet. Keeping these policies explicit avoids making individual widgets reproduce -container-level allocation policy. +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 diff --git a/promkit-core/src/render.rs b/promkit-core/src/render.rs index 9d598267..60363b31 100644 --- a/promkit-core/src/render.rs +++ b/promkit-core/src/render.rs @@ -6,12 +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. -//! Content-sized items are allocated in key order while reserving at least one +//! 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::FairFill`]. -//! [`crate::HeightPolicy::FairContent`] is reserved and currently produces an -//! error. A terminal that cannot provide one row per non-empty item also -//! produces an error. +//! 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 c5fd8351..310d864e 100644 --- a/promkit-core/src/render/layout.rs +++ b/promkit-core/src/render/layout.rs @@ -153,16 +153,13 @@ impl RendererLayout { let desired_heights = laid_out .iter() .map(|(_, layout, _, rows)| match layout.height_policy { - HeightPolicy::OrderedContent => { - Ok(layout.max_height.unwrap_or(rows.len()).min(rows.len())) + HeightPolicy::OrderedContent | HeightPolicy::FairContent => { + layout.max_height.unwrap_or(rows.len()).min(rows.len()) } - HeightPolicy::FairFill => Ok(layout.max_height.unwrap_or(terminal_height as usize)), - HeightPolicy::FairContent => Err(anyhow::anyhow!( - "HeightPolicy::FairContent is not implemented" - )), + HeightPolicy::FairFill => layout.max_height.unwrap_or(terminal_height as usize), }) - .map(|height| height.map(|height| height.max(1))) - .collect::>>()?; + .map(|height| height.max(1)) + .collect::>(); let heights = allocate_heights( &desired_heights, &laid_out @@ -223,8 +220,7 @@ fn allocate_heights(desired: &[usize], policies: &[HeightPolicy], available: usi for (index, (&desired, &policy)) in desired.iter().zip(policies).enumerate() { match policy { HeightPolicy::OrderedContent => {} - HeightPolicy::FairFill => continue, - HeightPolicy::FairContent => unreachable!("rejected before height allocation"), + HeightPolicy::FairContent | HeightPolicy::FairFill => continue, } let extra = desired.saturating_sub(1).min(remaining); heights[index] = heights[index].saturating_add(extra); @@ -234,7 +230,7 @@ fn allocate_heights(desired: &[usize], policies: &[HeightPolicy], available: usi while remaining > 0 { let mut distributed = false; for (index, policy) in policies.iter().enumerate() { - if *policy == HeightPolicy::FairFill && heights[index] < desired[index] { + if matches!(policy, HeightPolicy::FairContent | HeightPolicy::FairFill) { heights[index] += 1; remaining -= 1; distributed = true; @@ -248,6 +244,38 @@ fn allocate_heights(desired: &[usize], policies: &[HeightPolicy], available: usi } } + let mut redistributable = 0; + for (index, policy) in policies.iter().enumerate() { + if *policy != HeightPolicy::FairFill || heights[index] <= desired[index] { + continue; + } + redistributable += heights[index] - desired[index]; + heights[index] = desired[index]; + } + + for (index, policy) in policies.iter().enumerate() { + if *policy == HeightPolicy::FairContent { + heights[index] = heights[index].min(desired[index]); + } + } + + while redistributable > 0 { + let mut distributed = false; + for (index, policy) in policies.iter().enumerate() { + if *policy == HeightPolicy::FairFill && heights[index] < desired[index] { + heights[index] += 1; + redistributable -= 1; + distributed = true; + if redistributable == 0 { + break; + } + } + } + if !distributed { + break; + } + } + heights } @@ -465,7 +493,7 @@ mod tests { } #[test] - fn shares_height_equally_between_fill_entries() { + fn shares_height_equally_between_fair_fill_entries() { assert_eq!( allocate_heights( &[2, 10, 10], @@ -481,7 +509,7 @@ mod tests { } #[test] - fn reallocates_height_after_a_fill_entry_reaches_its_limit() { + fn reallocates_height_after_a_fair_fill_entry_reaches_its_limit() { assert_eq!( allocate_heights( &[2, 20, 3], @@ -495,6 +523,22 @@ mod tests { [2, 7, 3] ); } + + #[test] + fn keeps_fair_content_within_its_equal_share() { + assert_eq!( + allocate_heights( + &[1, 10, 10], + &[ + HeightPolicy::FairContent, + HeightPolicy::FairContent, + HeightPolicy::FairContent, + ], + 8, + ), + [1, 3, 2] + ); + } } mod visual_position { @@ -654,8 +698,8 @@ mod tests { } #[test] - fn rejects_unimplemented_fair_content() { - let created = CreatedGraphemes { + fn does_not_pad_fair_content_beyond_content_height() { + let created = || CreatedGraphemes { graphemes: StyledGraphemes::from("content"), layout: WidgetLayout { height_policy: HeightPolicy::FairContent, @@ -665,11 +709,13 @@ mod tests { }; let mut layout = RendererLayout::default(); - let error = layout.layout([(0, created)], 80, 24).unwrap_err(); + let prepared = layout + .layout([(0, created()), (1, created())], 80, 6) + .unwrap(); assert_eq!( - error.to_string(), - "HeightPolicy::FairContent is not implemented" + prepared.panes().iter().map(Vec::len).collect::>(), + [1, 1] ); } } diff --git a/promkit-core/src/widget.rs b/promkit-core/src/widget.rs index 17cc8557..d1ff3cf8 100644 --- a/promkit-core/src/widget.rs +++ b/promkit-core/src/widget.rs @@ -74,10 +74,8 @@ pub enum HeightPolicy { /// [`WidgetLayout::max_height`] and the remaining terminal height. #[default] OrderedContent, - /// Share the remaining height fairly, but do not exceed the content height. - /// - /// This policy is reserved for a future implementation. The renderer - /// currently returns an error for a non-empty widget that uses it. + /// 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`]. @@ -90,9 +88,10 @@ pub enum HeightPolicy { /// combines it with the laid-out content height, terminal height, and the other /// non-empty widgets. [`HeightPolicy::OrderedContent`] uses that content-derived /// height in widget order, while [`HeightPolicy::FairFill`] shares the remaining -/// terminal height equally with other fill widgets. [`HeightPolicy::FairContent`] -/// is reserved and not yet implemented. `width_mode` controls whether each -/// logical row wraps or is truncated with an ellipsis. +/// 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, diff --git a/tests/height-policy/src/height_policy_fixture.rs b/tests/height-policy/src/height_policy_fixture.rs index 020e3cf0..1a8c165d 100644 --- a/tests/height-policy/src/height_policy_fixture.rs +++ b/tests/height-policy/src/height_policy_fixture.rs @@ -1,4 +1,4 @@ -use std::io; +use std::{env, io}; use futures::StreamExt; use promkit_core::{ @@ -36,13 +36,20 @@ async fn main() -> anyhow::Result<()> { execute!(io::stdout(), cursor::Hide)?; let _terminal_guard = TerminalGuard; - let renderer = Renderer::try_new_with_graphemes(ordered_content(), true).await?; + 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 key.kind == KeyEventKind::Press && key.code == KeyCode::Tab => { - renderer.update(fair_fill()).render().await?; + 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 @@ -57,6 +64,15 @@ async fn main() -> anyhow::Result<()> { 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] { [ ( @@ -92,6 +108,41 @@ fn fair_fill() -> [(Index, CreatedGraphemes); 4] { ] } +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), 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/height_policies_allocate_terminal_rows.th b/tests/height-policy/tests/scenarios/height_policies_allocate_terminal_rows.th deleted file mode 100644 index 25ef1e07..00000000 --- a/tests/height-policy/tests/scenarios/height_policies_allocate_terminal_rows.th +++ /dev/null @@ -1,29 +0,0 @@ -Scenario "height_policies_allocate_terminal_rows" -Command "CARGO_BIN_EXE_height-policy-fixture" -Terminal rows 8 cols 16 -Cursor row 8 col 1 - -Step "ordered 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·····| - -Step "fair fill shares and preserves the remaining height" -Input tab -Settle 100ms -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 index ad434d2b..b7994f55 100644 --- a/tests/height-policy/tests/terminal_scenarios.rs +++ b/tests/height-policy/tests/terminal_scenarios.rs @@ -1,9 +1,19 @@ use termharness::{error::Result, scenario}; #[test] -fn height_policies_allocate_terminal_rows() -> Result<()> { - scenario::run_document(include_str!( - "scenarios/height_policies_allocate_terminal_rows.th" - ))?; +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(()) } From fc43fe285bac1784e343c640a5fff54f4b929586 Mon Sep 17 00:00:00 2001 From: ynqa Date: Sat, 15 Aug 2026 21:45:57 +0900 Subject: [PATCH 11/11] Extract height allocation from renderer layout --- promkit-core/src/render/layout.rs | 216 ++++++----------------- promkit-core/src/render/layout/height.rs | 209 ++++++++++++++++++++++ 2 files changed, 259 insertions(+), 166 deletions(-) create mode 100644 promkit-core/src/render/layout/height.rs diff --git a/promkit-core/src/render/layout.rs b/promkit-core/src/render/layout.rs index 310d864e..eb9051fc 100644 --- a/promkit-core/src/render/layout.rs +++ b/promkit-core/src/render/layout.rs @@ -4,10 +4,13 @@ use crate::{ grapheme::StyledGraphemes, widget::{ ContentPosition, CreatedGraphemes, HeightPolicy, ScreenPosition, VisualPosition, - WidgetViewport, WidthMode, + 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, @@ -140,9 +165,14 @@ 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 { @@ -150,52 +180,43 @@ impl RendererLayout { } let pane_count = laid_out.len(); - let desired_heights = laid_out + let height_requests = laid_out .iter() - .map(|(_, layout, _, rows)| match layout.height_policy { - HeightPolicy::OrderedContent | HeightPolicy::FairContent => { - layout.max_height.unwrap_or(rows.len()).min(rows.len()) - } - HeightPolicy::FairFill => layout.max_height.unwrap_or(terminal_height as usize), - }) - .map(|height| height.max(1)) + .map(LaidOutPane::height_request) .collect::>(); - let heights = allocate_heights( - &desired_heights, - &laid_out - .iter() - .map(|(_, layout, _, _)| layout.height_policy) - .collect::>(), - terminal_height as usize, - ); + let heights = height::allocate(&height_requests, terminal_height as usize); let mut entries = Vec::with_capacity(pane_count); - for ((index, layout, cursor, mut rows), height) in laid_out.into_iter().zip(heights) { - if layout.height_policy == HeightPolicy::FairFill && rows.len() < height { - pad_rows_to_height(&mut rows, 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, }); } @@ -210,75 +231,6 @@ impl RendererLayout { } } -fn allocate_heights(desired: &[usize], policies: &[HeightPolicy], available: usize) -> Vec { - debug_assert_eq!(desired.len(), policies.len()); - debug_assert!(desired.len() <= available); - - let mut heights = vec![1usize; desired.len()]; - let mut remaining = available.saturating_sub(heights.len()); - - for (index, (&desired, &policy)) in desired.iter().zip(policies).enumerate() { - match policy { - HeightPolicy::OrderedContent => {} - HeightPolicy::FairContent | HeightPolicy::FairFill => continue, - } - let extra = desired.saturating_sub(1).min(remaining); - heights[index] = heights[index].saturating_add(extra); - remaining = remaining.saturating_sub(extra); - } - - while remaining > 0 { - let mut distributed = false; - for (index, policy) in policies.iter().enumerate() { - if matches!(policy, HeightPolicy::FairContent | HeightPolicy::FairFill) { - heights[index] += 1; - remaining -= 1; - distributed = true; - if remaining == 0 { - break; - } - } - } - if !distributed { - break; - } - } - - let mut redistributable = 0; - for (index, policy) in policies.iter().enumerate() { - if *policy != HeightPolicy::FairFill || heights[index] <= desired[index] { - continue; - } - redistributable += heights[index] - desired[index]; - heights[index] = desired[index]; - } - - for (index, policy) in policies.iter().enumerate() { - if *policy == HeightPolicy::FairContent { - heights[index] = heights[index].min(desired[index]); - } - } - - while redistributable > 0 { - let mut distributed = false; - for (index, policy) in policies.iter().enumerate() { - if *policy == HeightPolicy::FairFill && heights[index] < desired[index] { - heights[index] += 1; - redistributable -= 1; - distributed = true; - if redistributable == 0 { - break; - } - } - } - if !distributed { - break; - } - } - - heights -} - fn pad_rows_to_height(rows: &mut Vec, height: usize) { let first_padding_row = rows .last() @@ -473,74 +425,6 @@ mod tests { use super::*; use crate::widget::WidgetLayout; - mod allocate_heights { - use super::*; - - #[test] - fn preserves_content_allocation_in_key_order() { - assert_eq!( - allocate_heights( - &[10, 10, 10], - &[ - HeightPolicy::OrderedContent, - HeightPolicy::OrderedContent, - HeightPolicy::OrderedContent, - ], - 8, - ), - [6, 1, 1] - ); - } - - #[test] - fn shares_height_equally_between_fair_fill_entries() { - assert_eq!( - allocate_heights( - &[2, 10, 10], - &[ - HeightPolicy::OrderedContent, - HeightPolicy::FairFill, - HeightPolicy::FairFill, - ], - 10, - ), - [2, 4, 4] - ); - } - - #[test] - fn reallocates_height_after_a_fair_fill_entry_reaches_its_limit() { - assert_eq!( - allocate_heights( - &[2, 20, 3], - &[ - HeightPolicy::OrderedContent, - HeightPolicy::FairFill, - HeightPolicy::FairFill, - ], - 12, - ), - [2, 7, 3] - ); - } - - #[test] - fn keeps_fair_content_within_its_equal_share() { - assert_eq!( - allocate_heights( - &[1, 10, 10], - &[ - HeightPolicy::FairContent, - HeightPolicy::FairContent, - HeightPolicy::FairContent, - ], - 8, - ), - [1, 3, 2] - ); - } - } - mod visual_position { use super::*; 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]); + } + } +}