From f38b71011b3ec689f860414ec4e61132db93998b Mon Sep 17 00:00:00 2001 From: barsoosayque Date: Sat, 11 Oct 2025 22:41:52 +0700 Subject: [PATCH 01/12] feat: Add UseComponentRect --- packages/iocraft/src/hooks/mod.rs | 2 + .../iocraft/src/hooks/use_component_rect.rs | 88 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 packages/iocraft/src/hooks/use_component_rect.rs diff --git a/packages/iocraft/src/hooks/mod.rs b/packages/iocraft/src/hooks/mod.rs index 6aed5be..192ff70 100644 --- a/packages/iocraft/src/hooks/mod.rs +++ b/packages/iocraft/src/hooks/mod.rs @@ -62,3 +62,5 @@ mod use_terminal_events; pub use use_terminal_events::*; mod use_terminal_size; pub use use_terminal_size::*; +mod use_component_rect; +pub use use_component_rect::*; diff --git a/packages/iocraft/src/hooks/use_component_rect.rs b/packages/iocraft/src/hooks/use_component_rect.rs new file mode 100644 index 0000000..0fd00d9 --- /dev/null +++ b/packages/iocraft/src/hooks/use_component_rect.rs @@ -0,0 +1,88 @@ +use taffy::Rect; + +use crate::{ComponentDrawer, Hook, Hooks}; + +mod private { + pub trait Sealed {} + impl Sealed for crate::Hooks<'_, '_> {} +} + +/// `UseComponentRect` is a hook that returns the current component canvas position and size. +/// +/// See [`ComponentDrawer::canvas_position`] and [`ComponentDrawer::size`] for more info. +pub trait UseComponentRect<'a> { + /// Returns the curent component canvas position and size in form of a [`Rect`]. + fn use_component_rect(&mut self) -> Rect; +} + +impl<'a> UseComponentRect<'a> for Hooks<'a, '_> { + fn use_component_rect(&mut self) -> Rect { + self.use_hook(UseComponentRectImpl::default).rect + } +} + +#[derive(Default)] +struct UseComponentRectImpl { + rect: Rect, +} + +impl Hook for UseComponentRectImpl { + fn pre_component_draw(&mut self, drawer: &mut ComponentDrawer) { + let size = drawer.size(); + let position = drawer.canvas_position(); + self.rect = Rect { + left: position.x as u16, + right: position.x as u16 + size.width as u16, + top: position.y as u16, + bottom: position.y as u16 + size.height as u16, + }; + } +} + +#[cfg(test)] +mod tests { + use crate::{hooks::use_component_rect::UseComponentRect, prelude::*}; + use futures::stream::StreamExt; + use macro_rules_attribute::apply; + use smol_macros::test; + + #[component] + fn MyComponent(mut hooks: Hooks) -> impl Into> { + let mut should_exit = hooks.use_state(|| false); + let mut system = hooks.use_context_mut::(); + let rect = hooks.use_component_rect(); + + hooks.use_terminal_events(move |event| match event { + TerminalEvent::Resize(..) => should_exit.set(true), + _ => {} + }); + + if should_exit.get() { + system.exit(); + } + + element! { + Text(content: format!("{}:{}:{}:{}", rect.left, rect.right, rect.top, rect.bottom)) + + } + } + + #[apply(test!)] + async fn test_use_component_rect() { + let actual = element!( + View( + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + width: 40, + height: 50, + ) { MyComponent } + ) + .mock_terminal_render_loop(MockTerminalConfig::with_events(futures::stream::iter( + vec![TerminalEvent::Resize(40, 50)], + ))) + .map(|c| c.to_string()) + .collect::>() + .await; + assert_eq!(actual.last().unwrap().trim(), "17:24:25:26"); + } +} From 2f619cbec8cd80e6d655f885927085e20cb8ecef Mon Sep 17 00:00:00 2001 From: barsoosayque Date: Sat, 11 Oct 2025 22:46:35 +0700 Subject: [PATCH 02/12] use sealed on UseComponentRect --- packages/iocraft/src/hooks/use_component_rect.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/iocraft/src/hooks/use_component_rect.rs b/packages/iocraft/src/hooks/use_component_rect.rs index 0fd00d9..6e2066b 100644 --- a/packages/iocraft/src/hooks/use_component_rect.rs +++ b/packages/iocraft/src/hooks/use_component_rect.rs @@ -10,7 +10,7 @@ mod private { /// `UseComponentRect` is a hook that returns the current component canvas position and size. /// /// See [`ComponentDrawer::canvas_position`] and [`ComponentDrawer::size`] for more info. -pub trait UseComponentRect<'a> { +pub trait UseComponentRect<'a>: private::Sealed { /// Returns the curent component canvas position and size in form of a [`Rect`]. fn use_component_rect(&mut self) -> Rect; } From 0fc31a8869d4c00079e71cafe29c44c54db39bad Mon Sep 17 00:00:00 2001 From: barsoosayque Date: Sat, 11 Oct 2025 23:08:28 +0700 Subject: [PATCH 03/12] remove unnecessary cast --- packages/iocraft/src/hooks/use_component_rect.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/iocraft/src/hooks/use_component_rect.rs b/packages/iocraft/src/hooks/use_component_rect.rs index 6e2066b..eef9b71 100644 --- a/packages/iocraft/src/hooks/use_component_rect.rs +++ b/packages/iocraft/src/hooks/use_component_rect.rs @@ -32,9 +32,9 @@ impl Hook for UseComponentRectImpl { let position = drawer.canvas_position(); self.rect = Rect { left: position.x as u16, - right: position.x as u16 + size.width as u16, + right: position.x as u16 + size.width, top: position.y as u16, - bottom: position.y as u16 + size.height as u16, + bottom: position.y as u16 + size.height, }; } } From ee6089e94c487003a49de45eeed68331963691ee Mon Sep 17 00:00:00 2001 From: barsoosayque Date: Sun, 12 Oct 2025 01:44:11 +0700 Subject: [PATCH 04/12] improve documentation and use ref for rect --- .../iocraft/src/hooks/use_component_rect.rs | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/iocraft/src/hooks/use_component_rect.rs b/packages/iocraft/src/hooks/use_component_rect.rs index eef9b71..4e5a592 100644 --- a/packages/iocraft/src/hooks/use_component_rect.rs +++ b/packages/iocraft/src/hooks/use_component_rect.rs @@ -1,41 +1,47 @@ use taffy::Rect; -use crate::{ComponentDrawer, Hook, Hooks}; +use crate::{ + hooks::{Ref, UseRef}, + ComponentDrawer, Hook, Hooks, +}; mod private { pub trait Sealed {} impl Sealed for crate::Hooks<'_, '_> {} } -/// `UseComponentRect` is a hook that returns the current component canvas position and size. +/// `UseComponentRect` is a hook that returns the current component's canvas position and size +/// from the previous frame. /// /// See [`ComponentDrawer::canvas_position`] and [`ComponentDrawer::size`] for more info. +/// +///
For the first time rendering, it will return exactly (0, 0, 0, 0) rectangle !
pub trait UseComponentRect<'a>: private::Sealed { /// Returns the curent component canvas position and size in form of a [`Rect`]. - fn use_component_rect(&mut self) -> Rect; + fn use_component_rect(&mut self) -> Ref>; } impl<'a> UseComponentRect<'a> for Hooks<'a, '_> { - fn use_component_rect(&mut self) -> Rect { - self.use_hook(UseComponentRectImpl::default).rect + fn use_component_rect(&mut self) -> Ref> { + let rect = self.use_ref_default(); + self.use_hook(|| UseComponentRectImpl { rect }).rect } } -#[derive(Default)] struct UseComponentRectImpl { - rect: Rect, + rect: Ref>, } impl Hook for UseComponentRectImpl { fn pre_component_draw(&mut self, drawer: &mut ComponentDrawer) { let size = drawer.size(); let position = drawer.canvas_position(); - self.rect = Rect { + self.rect.set(Rect { left: position.x as u16, right: position.x as u16 + size.width, top: position.y as u16, bottom: position.y as u16 + size.height, - }; + }); } } @@ -48,18 +54,15 @@ mod tests { #[component] fn MyComponent(mut hooks: Hooks) -> impl Into> { - let mut should_exit = hooks.use_state(|| false); let mut system = hooks.use_context_mut::(); - let rect = hooks.use_component_rect(); - - hooks.use_terminal_events(move |event| match event { - TerminalEvent::Resize(..) => should_exit.set(true), - _ => {} - }); + let mut frame = hooks.use_state(|| 0); + let rect = hooks.use_component_rect().get(); - if should_exit.get() { + // Notice that we have to wait one frame for the correct size and position. + if frame.get() >= 1 { system.exit(); } + frame += 1; element! { Text(content: format!("{}:{}:{}:{}", rect.left, rect.right, rect.top, rect.bottom)) @@ -77,9 +80,7 @@ mod tests { height: 50, ) { MyComponent } ) - .mock_terminal_render_loop(MockTerminalConfig::with_events(futures::stream::iter( - vec![TerminalEvent::Resize(40, 50)], - ))) + .mock_terminal_render_loop(MockTerminalConfig::default()) .map(|c| c.to_string()) .collect::>() .await; From 786adb0fb79835d4e696c2c204cc3e10e94263d8 Mon Sep 17 00:00:00 2001 From: barsoosayque Date: Sat, 18 Oct 2025 12:44:28 +0700 Subject: [PATCH 05/12] make use_component_rect reactive and return option --- .../iocraft/src/hooks/use_component_rect.rs | 57 +++++++++++++------ 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/packages/iocraft/src/hooks/use_component_rect.rs b/packages/iocraft/src/hooks/use_component_rect.rs index 4e5a592..f6ba8fb 100644 --- a/packages/iocraft/src/hooks/use_component_rect.rs +++ b/packages/iocraft/src/hooks/use_component_rect.rs @@ -1,3 +1,7 @@ +use std::{ + pin::Pin, + task::{Context, Poll}, +}; use taffy::Rect; use crate::{ @@ -10,38 +14,59 @@ mod private { impl Sealed for crate::Hooks<'_, '_> {} } +/// [`Ref`] with component's drawer rect. +pub type ComponentRectRef = Ref>>; + /// `UseComponentRect` is a hook that returns the current component's canvas position and size -/// from the previous frame. +/// from the previous frame, or `None` if it's the first frame. /// /// See [`ComponentDrawer::canvas_position`] and [`ComponentDrawer::size`] for more info. -/// -///
For the first time rendering, it will return exactly (0, 0, 0, 0) rectangle !
pub trait UseComponentRect<'a>: private::Sealed { /// Returns the curent component canvas position and size in form of a [`Rect`]. - fn use_component_rect(&mut self) -> Ref>; + fn use_component_rect(&mut self) -> ComponentRectRef; } impl<'a> UseComponentRect<'a> for Hooks<'a, '_> { - fn use_component_rect(&mut self) -> Ref> { + fn use_component_rect(&mut self) -> ComponentRectRef { let rect = self.use_ref_default(); - self.use_hook(|| UseComponentRectImpl { rect }).rect + self.use_hook(move || UseComponentRectImpl { + rect, + is_changed: false, + }) + .rect } } struct UseComponentRectImpl { - rect: Ref>, + rect: ComponentRectRef, + is_changed: bool, } impl Hook for UseComponentRectImpl { + fn poll_change(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<()> { + if self.is_changed { + Poll::Ready(()) + } else { + Poll::Pending + } + } + fn pre_component_draw(&mut self, drawer: &mut ComponentDrawer) { let size = drawer.size(); let position = drawer.canvas_position(); - self.rect.set(Rect { + let rect = Rect { left: position.x as u16, right: position.x as u16 + size.width, top: position.y as u16, bottom: position.y as u16 + size.height, - }); + }; + + if self.rect.get() != Some(rect) { + self.rect.set(Some(rect)); + self.is_changed = true; + } else if self.rect.get().is_some() { + self.is_changed = false; + } } } @@ -55,18 +80,16 @@ mod tests { #[component] fn MyComponent(mut hooks: Hooks) -> impl Into> { let mut system = hooks.use_context_mut::(); - let mut frame = hooks.use_state(|| 0); let rect = hooks.use_component_rect().get(); - // Notice that we have to wait one frame for the correct size and position. - if frame.get() >= 1 { - system.exit(); - } - frame += 1; + let Some(rect) = rect else { + return element! { Text(content: "00:00:00:00") }; + }; + + system.exit(); element! { Text(content: format!("{}:{}:{}:{}", rect.left, rect.right, rect.top, rect.bottom)) - } } @@ -84,6 +107,6 @@ mod tests { .map(|c| c.to_string()) .collect::>() .await; - assert_eq!(actual.last().unwrap().trim(), "17:24:25:26"); + assert_eq!(actual.last().unwrap().trim(), "15:26:25:26"); } } From 808a3331e92680e38f70f7464c3b1943cf8bc43c Mon Sep 17 00:00:00 2001 From: Lorenz Schmidt Date: Fri, 20 Feb 2026 15:29:38 +0000 Subject: [PATCH 06/12] feat: add man page picker and border titles Extend the example suitcase with a man-page picker modeled after the Telescope Neovim plugin. You can scroll through a list of _results_ filtered by a _prompt_ in two independent views. Also adds a `border_title` key to `ViewProps` for rendering a string overlay centered at either bottom or top border. --- examples/picker.rs | 211 ++++++++++++++++++++++++ packages/iocraft/src/components/view.rs | 71 +++++++- 2 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 examples/picker.rs diff --git a/examples/picker.rs b/examples/picker.rs new file mode 100644 index 0000000..9de4650 --- /dev/null +++ b/examples/picker.rs @@ -0,0 +1,211 @@ +use iocraft::prelude::*; + +use std::process::{Command, Stdio}; +use std::io; + +#[derive(Clone, Debug)] +struct ManPage { + key: String, + title: String, +} + +fn parse_man_output(output: &str) -> Vec { + let mut man_pages = Vec::new(); + + for line in output.lines().map(|x| x.trim()).filter(|x| !x.is_empty()) { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 2 { + continue; // malformed + } + + // The key is the first part (e.g., "arandr") + let key = parts[0].trim_end_matches('(').to_string(); + + // The title is the rest of the line after the key and section (e.g., "visual front end for XRandR 1.2") + let title_start = line.find(" - ").map_or(line.len(), |pos| pos + 3); + let title = line[title_start..].trim().to_string(); + + man_pages.push(ManPage { title, key }); + } + + man_pages +} + +fn matches(key: &str, query: &str) -> Option> { + if query == "" { + return Some(vec![MixedTextContent::new(key.to_owned())]); + } + + let mut elms = vec![]; + let mut last = 0; + + while let Some(pos) = key[last..].find(query) { + elms.push(MixedTextContent::new(&key[last..last + pos])); + elms.push(MixedTextContent::new(&key[last + pos..last + pos + query.len()]).color(Color::Red).weight(Weight::Bold)); + + last += pos + query.len(); + } + if last < key.len() { + elms.push(MixedTextContent::new(&key[last..])); + } + + if elms.len() > 1 { + Some(elms) + } else { + None + } +} + +fn get_man_pages() -> io::Result> { + let output = Command::new("man") + .args(["-k", ".", "-s", "1"]) + .output()?; + + if !output.status.success() { + return Err(io::Error::new( + io::ErrorKind::Other, + format!("Command failed: {}", String::from_utf8_lossy(&output.stderr)), + )); + } + + let output_str = String::from_utf8(output.stdout).map_err(|e| { + io::Error::new(io::ErrorKind::InvalidData, e) + })?; + + Ok(parse_man_output(&output_str)) +} + +#[derive(Props, Default)] +struct PromptProps { + title: String, + show_carrot: bool, + prompt: Option>, + nelms: (usize, usize), +} + +#[component] +fn Prompt<'a>(props: &'a PromptProps, _hooks: Hooks) -> impl Into> { + let Some(mut value) = props.prompt else { + panic!("value is required"); + }; + + element! { + View(border_style: BorderStyle::Round, flex_grow: 1.0, height: 3, border_title: Some(BorderTitle { title: props.title.clone(), pos: BorderTitlePos::Bottom })) { + Fragment { + #( if props.show_carrot { Some( + element! { Text(content: ">", color: Some(Color::Red)) }) + } else { None } + ) + View(flex_grow: 1.0, background_color: Color::DarkGrey) { + TextInput(has_focus: true, value: value.to_string(), on_change: move |new_value| value.set(new_value)) + } + Text(content: format!(" {}/{}", props.nelms.0, props.nelms.1), color: Some(Color::DarkGrey)) + } + } + } +} + +#[derive(Props, Default)] +struct ResultsProps { + elms: Vec<(String, Vec)>, +} + +#[component] +fn Results<'a>(props: &'a ResultsProps, mut hooks: Hooks) -> impl Into> { + let max_len = hooks.use_memo(|| + props.elms.iter().map(|x| x.0.len()).max().unwrap_or(0) as u32, + &props.elms.iter().map(|x| x.0.clone()).collect::()); + + let max_len = u32::min(max_len, 17); + let mut current_idx = hooks.use_state(|| 0isize); + let mut beginning = hooks.use_state(|| 0); + + if current_idx.get() < 0 { + current_idx.set(props.elms.len() as isize - 1); + } else if current_idx.get() >= props.elms.len() as isize && props.elms.len() > 0 { + current_idx.set(0); + } + + if beginning > current_idx.get() { + beginning.set(current_idx.get()); + } else if current_idx.get() > beginning + 17 { + beginning.set(current_idx.get() - 17); + } + + let nprops = props.elms.len(); + let current_key = match props.elms.len() { + 0 => None, + _ => Some(props.elms[current_idx.get() as usize].0.clone()) + }; + + hooks.use_terminal_events({ + move |event| match event { + TerminalEvent::Key(KeyEvent { code, kind, .. }) if kind != KeyEventKind::Release => { + match code { + KeyCode::Up => current_idx.set(current_idx.get() - 1), + KeyCode::Down => current_idx.set(isize::min(current_idx.get() + 1, nprops as isize)), + KeyCode::Enter => { + current_key.as_ref().map(|current_key| { + let _ = Command::new("man").arg(¤t_key) + .stdout(Stdio::inherit()) + .stdin(Stdio::inherit()) + .output().unwrap(); + }); + }, + _ => {} + } + } + _ => {} + } + }); + + element! { + View(flex_grow: 1.0, flex_direction: FlexDirection::Column, height: 20, border_style: BorderStyle::Round, overflow: Some(Overflow::Hidden), border_title: Some(BorderTitle { title: "Results".to_owned(), pos: BorderTitlePos::Top })) { + #(props.elms.iter().enumerate().skip(beginning.get() as usize) + .map(|(idx, mat)| if current_idx.get() as usize == idx { + (Color::DarkGrey, mat) + } else { + (Color::Reset, mat) + }) + .map(|(color, mat)| element! { + View(flex_direction: FlexDirection::Row, background_color: Some(color)) { + View(width: max_len + 2, height: 1) { Text(content: mat.0.clone(), color: Some(Color::Cyan), weight: Weight::Bold) } + View(width: 80 - max_len - 2) {MixedText(contents: mat.1.clone(), wrap: TextWrap::NoWrap) } + } + }).take(18)) + } + } +} + +#[derive(Props, Default)] +struct ManPicker; + +#[component] +fn Picker<'a>(_props: &'a ManPicker, mut hooks: Hooks) -> impl Into> { + let pages = hooks.use_const(|| get_man_pages().unwrap()); + let prompt: State = hooks.use_state_default(); + + let elms = hooks.use_memo(|| + pages.iter().filter_map(|page| matches(&page.title, &prompt.read().as_str()).map(|x| (page.key.clone(), x))).collect::>(), &prompt); + + let nelms = (elms.len(), pages.len()); + element! { + View(flex_direction: FlexDirection::Column, width: Size::Percent(100.0)) { + Results(elms: elms) + Prompt(prompt: prompt, show_carrot: true, nelms, title: "Man Pages".to_owned()) + } + } + +} + +fn main() { + smol::block_on( + element! { + View(width: Size::Length(80)) { + Picker() + } + } + .render_loop() + ) + .unwrap(); +} diff --git a/packages/iocraft/src/components/view.rs b/packages/iocraft/src/components/view.rs index 2ee3da3..b6cfd56 100644 --- a/packages/iocraft/src/components/view.rs +++ b/packages/iocraft/src/components/view.rs @@ -135,6 +135,43 @@ impl BorderStyle { } } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BorderTitlePos { + #[default] + Top, + Bottom +} + +/// The characters used to render a custom border for a [`View`]. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BorderTitle { + pub title: String, + pub pos: BorderTitlePos, +} + +fn center_and_clip(border_char: &str, length: usize, title: &str) -> String { + let title_len = title.chars().count(); + + // If inner is longer, clip it + let (clipped_title, clipped_len) = if title_len > length { + (title.chars().take(length).collect(), length) + } else { + (title.to_string(), title_len) + }; + + // Calculate padding + let total_padding = length.saturating_sub(clipped_len); + let left_padding = total_padding / 2; + let right_padding = total_padding - left_padding; + + format!( + "{}{}{}", + border_char.repeat(left_padding), + clipped_title, + border_char.repeat(right_padding) + ) +} + /// The props which can be passed to the [`View`] component. #[non_exhaustive] #[with_layout_style_props] @@ -152,6 +189,9 @@ pub struct ViewProps<'a> { /// The edges to render the border on. By default, the border will be rendered on all edges. pub border_edges: Option, + /// The color of the border. + pub border_title: Option, + /// The color of the background. pub background_color: Option, } @@ -176,6 +216,7 @@ pub struct View { border_text_style: CanvasTextStyle, border_edges: Edges, background_color: Option, + border_title: Option, } impl Component for View { @@ -198,6 +239,8 @@ impl Component for View { }; self.border_edges = props.border_edges.unwrap_or(Edges::all()); self.background_color = props.background_color; + self.border_title = props.border_title.clone(); + let mut style: taffy::style::Style = props.layout_style().into(); style.border = if self.border_style.is_none() { Rect::zero() @@ -277,10 +320,16 @@ impl Component for View { canvas.set_text(0, 0, &border.top_left.to_string(), self.border_text_style); } - let top = border - .top - .to_string() - .repeat(layout.size.width as usize - left_border_size - right_border_size); + let (top_size, top_char) = ( + layout.size.width as usize - left_border_size - right_border_size, + border.top.to_string(), + ); + let top = match self.border_title { + Some(ref title) if title.pos == BorderTitlePos::Top => + center_and_clip(&top_char, top_size, &title.title), + _ => top_char.repeat(top_size), + }; + canvas.set_text(left_border_size as _, 0, &top, self.border_text_style); if self.border_edges.contains(Edges::Right) { @@ -317,10 +366,16 @@ impl Component for View { ); } - let bottom = border - .bottom - .to_string() - .repeat(layout.size.width as usize - left_border_size - right_border_size); + let (bottom_size, bottom_char) = ( + layout.size.width as usize - left_border_size - right_border_size, + border.bottom.to_string(), + ); + let bottom = match self.border_title { + Some(ref title) if title.pos == BorderTitlePos::Bottom => + center_and_clip(&bottom_char, bottom_size, &title.title), + _ => bottom_char.repeat(bottom_size), + }; + canvas.set_text( left_border_size as _, layout.size.height as isize - 1, From a327d8aac94a00035c42c4aa1b175ca07bfff2c1 Mon Sep 17 00:00:00 2001 From: Lorenz Schmidt Date: Wed, 25 Feb 2026 10:58:25 +0000 Subject: [PATCH 07/12] feat(manpicker): add page preview; add hori/vert layouts --- examples/picker.rs | 269 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 232 insertions(+), 37 deletions(-) diff --git a/examples/picker.rs b/examples/picker.rs index 9de4650..5d2af89 100644 --- a/examples/picker.rs +++ b/examples/picker.rs @@ -1,6 +1,7 @@ use iocraft::prelude::*; -use std::process::{Command, Stdio}; +//use std::process::{Command, Stdio}; +use smol::process::{Command, Stdio}; use std::io; #[derive(Clone, Debug)] @@ -9,13 +10,20 @@ struct ManPage { title: String, } +#[derive(Clone, Debug, Default, PartialEq)] +enum ManLayout { + #[default] + Vertical, + Horizontal, +} + fn parse_man_output(output: &str) -> Vec { let mut man_pages = Vec::new(); for line in output.lines().map(|x| x.trim()).filter(|x| !x.is_empty()) { let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() < 2 { - continue; // malformed + continue; } // The key is the first part (e.g., "arandr") @@ -57,7 +65,7 @@ fn matches(key: &str, query: &str) -> Option> { } fn get_man_pages() -> io::Result> { - let output = Command::new("man") + let output = std::process::Command::new("man") .args(["-k", ".", "-s", "1"]) .output()?; @@ -90,8 +98,11 @@ fn Prompt<'a>(props: &'a PromptProps, _hooks: Hooks) -> impl Into", color: Some(Color::Red)) }) } else { None } @@ -105,31 +116,153 @@ fn Prompt<'a>(props: &'a PromptProps, _hooks: Hooks) -> impl Into>(it: &mut std::iter::Peekable, e: char) -> Option { + let Some(c) = it.peek() else { + return None; + }; + + if *c != '\u{1b}' { + return None; + } + + it.next().unwrap(); + + let Some(c) = it.next() else { + return None; + }; + + if c != '[' { + return None; + }; + + if *it.peek().unwrap() == e { + return Some(0); + } + + let digit = it.take_while(|x| *x != e).collect::(); + + return Some(digit.parse().unwrap()); +} + +fn escape_chars_to_styling(content: &str) -> Vec { + let (mut text, mut bold, mut underline) = (String::new(), false, false); + let mut elms = Vec::new(); + let mut push = |text, bold, underline| { + elms.push(MixedTextContent::new(text) + .weight(if bold { Weight::Bold } else { Weight::Normal }) + .decoration(if underline { TextDecoration::Underline } else { TextDecoration::None })); + }; + + let mut it = content.chars().peekable(); + loop { + let mut is_text = false; + let sgr = find_sgr(&mut it, 'm'); + + match sgr { + Some(x) => { + push(text, bold, underline); + text = String::new(); + + match x { + 0 | 22 => {bold = false; underline = false;}, + 1 => bold = true, + 4 => underline = true, + 24 => underline = false, + x => {dbg!(x); panic!("");}, + } + }, + None => is_text = true, + }; + + if is_text { + if let Some(c) = it.next() { + text.push(c); + } else { + break; + } + } + } + + if text.len() > 0 { + push(text, bold, underline); + } + + elms +} + +#[derive(Props, Default)] +struct PreviewProps { + current: String, +} + +#[component] +fn Preview<'a>(props: &'a PreviewProps, mut hooks: Hooks) -> impl Into> { + let mut contents = hooks.use_state_default(); + let width = hooks.use_component_rect().get() + .map(|rect| rect.right - rect.left - 2); + + let update_page = hooks.use_async_handler(move |current: String| async move { + // do not render if width is not known yet + let Some(width) = width else { + return; + }; + + let res = Command::new("man").args(&[¤t.to_string()]) + .env("MANWIDTH", width.to_string()) + .env("MAN_KEEP_FORMATTING", "1") + .env("GROFF_SGR", "1") + .stdout(Stdio::piped()) + .output().await.unwrap(); + + contents.set(escape_chars_to_styling(str::from_utf8(&res.stdout).unwrap())); + }); + + // update content when page key or width changed + hooks.use_memo(|| update_page(props.current.clone()), (&props.current, width)); + + element! { + View(flex_grow: 1.0, flex_direction: FlexDirection::Column, border_style: BorderStyle::Round) { + View(height: 1, margin_top: -1, justify_content: JustifyContent::Center) { + Text(content: "Preview") + } + View(height: 100pct, overflow: Some(Overflow::Hidden)) { + MixedText(contents: contents.read().clone()) + } + } + } +} + #[derive(Props, Default)] struct ResultsProps { elms: Vec<(String, Vec)>, + current_idx: Option>, } #[component] fn Results<'a>(props: &'a ResultsProps, mut hooks: Hooks) -> impl Into> { + let Some(mut current_idx) = props.current_idx else { + panic!("value is required"); + }; + let max_len = hooks.use_memo(|| props.elms.iter().map(|x| x.0.len()).max().unwrap_or(0) as u32, &props.elms.iter().map(|x| x.0.clone()).collect::()); - let max_len = u32::min(max_len, 17); - let mut current_idx = hooks.use_state(|| 0isize); - let mut beginning = hooks.use_state(|| 0); + let (width, height) = match hooks.use_component_rect().get() { + Some(rect) => (rect.right - rect.left - 2, rect.bottom - rect.top - 2), + _ => (16, 16) + }; - if current_idx.get() < 0 { - current_idx.set(props.elms.len() as isize - 1); - } else if current_idx.get() >= props.elms.len() as isize && props.elms.len() > 0 { - current_idx.set(0); - } + let max_len = u32::min(max_len, height as u32); + let header_len = u32::max(width as u32 - max_len - 4, 4); + let key_len = width as u32 - header_len; + + let mut beginning = hooks.use_state(|| 0); if beginning > current_idx.get() { beginning.set(current_idx.get()); - } else if current_idx.get() > beginning + 17 { - beginning.set(current_idx.get() - 17); + } else if current_idx.get() > beginning + height as isize - 1 { + beginning.set(current_idx.get() - height as isize + 1); } let nprops = props.elms.len(); @@ -142,11 +275,23 @@ fn Results<'a>(props: &'a ResultsProps, mut hooks: Hooks) -> impl Into { match code { - KeyCode::Up => current_idx.set(current_idx.get() - 1), - KeyCode::Down => current_idx.set(isize::min(current_idx.get() + 1, nprops as isize)), + KeyCode::Up => { + if current_idx.get() - 1 < 0 { + current_idx.set(nprops as isize - 1); + } else { + current_idx.set(current_idx.get() - 1); + } + }, + KeyCode::Down => { + if current_idx.get() < nprops as isize - 1 { + current_idx.set(current_idx.get() + 1); + } else { + current_idx.set(0); + } + }, KeyCode::Enter => { current_key.as_ref().map(|current_key| { - let _ = Command::new("man").arg(¤t_key) + let _ = std::process::Command::new("man").arg(¤t_key) .stdout(Stdio::inherit()) .stdin(Stdio::inherit()) .output().unwrap(); @@ -160,19 +305,24 @@ fn Results<'a>(props: &'a ResultsProps, mut hooks: Hooks) -> impl Into(_props: &'a ManPicker, mut hooks: Hooks) -> impl Into> { + // global view of all pages let pages = hooks.use_const(|| get_man_pages().unwrap()); + + // query of the prompt element and selected element in the results lists + // are shared between two components let prompt: State = hooks.use_state_default(); + let current_idx = hooks.use_state(|| 0isize); + + // layout can be changed during runtime + let mut layout = hooks.use_state(|| ManLayout::Vertical); + // cache preview elements based on current prompt let elms = hooks.use_memo(|| - pages.iter().filter_map(|page| matches(&page.title, &prompt.read().as_str()).map(|x| (page.key.clone(), x))).collect::>(), &prompt); + pages.iter() + .filter_map(|page| matches(&page.title, &prompt.read().as_str()) + .map(|x| (page.key.clone(), x)) + ).collect::>() + , &prompt); let nelms = (elms.len(), pages.len()); - element! { - View(flex_direction: FlexDirection::Column, width: Size::Percent(100.0)) { - Results(elms: elms) - Prompt(prompt: prompt, show_carrot: true, nelms, title: "Man Pages".to_owned()) + + let key = hooks.use_memo(|| + elms[current_idx.get() as usize].0.clone(), + current_idx); + + hooks.use_terminal_events({ + move |event| match event { + TerminalEvent::Key(KeyEvent { code, kind, modifiers, .. }) if kind != KeyEventKind::Release && modifiers.contains(KeyModifiers::ALT) => { + match code { + KeyCode::Char('V') => layout.set(ManLayout::Vertical), + KeyCode::Char('H') => layout.set(ManLayout::Horizontal), + _ => {}, + } + }, + _ => {}, + }}); + + if *layout.read() == ManLayout::Vertical { + element! { + View(flex_direction: FlexDirection::Row, width: 100pct) { + View(flex_direction: FlexDirection::Column, width: 50pct) { + Results(elms: elms, current_idx) + Prompt(prompt: prompt, show_carrot: true, nelms, title: "Man Pages".to_owned()) + } + Preview(current: key) + } + } + } else { + element! { + View(flex_direction: FlexDirection::Column, width: 100pct) { + View(flex_direction: FlexDirection::Column, height: 50pct) { + Results(elms: elms, current_idx) + Prompt(prompt: prompt, show_carrot: true, nelms, title: "Man Pages".to_owned()) + } + Preview(current: key) + } } } @@ -201,7 +396,7 @@ fn Picker<'a>(_props: &'a ManPicker, mut hooks: Hooks) -> impl Into Date: Wed, 25 Feb 2026 11:30:14 +0000 Subject: [PATCH 08/12] fix(manpicker): check that binaries `man` and `ul` exist --- Cargo.toml | 1 + examples/picker.rs | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b9d11fd..ca98a83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,4 @@ surf = { version = "2.3.2", default-features = false, features = ["h1-client"] } serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.128" mexprp = { version = "0.3.1", default-features = false } +which = "8.0.0" diff --git a/examples/picker.rs b/examples/picker.rs index 5d2af89..699e64c 100644 --- a/examples/picker.rs +++ b/examples/picker.rs @@ -1,8 +1,8 @@ -use iocraft::prelude::*; +use std::io; -//use std::process::{Command, Stdio}; +use iocraft::prelude::*; use smol::process::{Command, Stdio}; -use std::io; +use which::which; #[derive(Clone, Debug)] struct ManPage { @@ -394,6 +394,15 @@ fn Picker<'a>(_props: &'a ManPicker, mut hooks: Hooks) -> impl Into Date: Wed, 25 Feb 2026 11:33:29 +0000 Subject: [PATCH 09/12] chore(manpicker): run fmt; remove border title props from view --- examples/picker.rs | 135 +++++++++++++++--------- packages/iocraft/src/components/view.rs | 71 ++----------- 2 files changed, 95 insertions(+), 111 deletions(-) diff --git a/examples/picker.rs b/examples/picker.rs index 699e64c..91f36b0 100644 --- a/examples/picker.rs +++ b/examples/picker.rs @@ -23,7 +23,7 @@ fn parse_man_output(output: &str) -> Vec { for line in output.lines().map(|x| x.trim()).filter(|x| !x.is_empty()) { let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() < 2 { - continue; + continue; } // The key is the first part (e.g., "arandr") @@ -49,7 +49,11 @@ fn matches(key: &str, query: &str) -> Option> { while let Some(pos) = key[last..].find(query) { elms.push(MixedTextContent::new(&key[last..last + pos])); - elms.push(MixedTextContent::new(&key[last + pos..last + pos + query.len()]).color(Color::Red).weight(Weight::Bold)); + elms.push( + MixedTextContent::new(&key[last + pos..last + pos + query.len()]) + .color(Color::Red) + .weight(Weight::Bold), + ); last += pos + query.len(); } @@ -72,13 +76,15 @@ fn get_man_pages() -> io::Result> { if !output.status.success() { return Err(io::Error::new( io::ErrorKind::Other, - format!("Command failed: {}", String::from_utf8_lossy(&output.stderr)), + format!( + "Command failed: {}", + String::from_utf8_lossy(&output.stderr) + ), )); } - let output_str = String::from_utf8(output.stdout).map_err(|e| { - io::Error::new(io::ErrorKind::InvalidData, e) - })?; + let output_str = String::from_utf8(output.stdout) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; Ok(parse_man_output(&output_str)) } @@ -104,7 +110,7 @@ fn Prompt<'a>(props: &'a PromptProps, _hooks: Hooks) -> impl Into", color: Some(Color::Red)) }) + element! { Text(content: ">", color: Some(Color::Red)) }) } else { None } ) View(flex_grow: 1.0, background_color: Color::DarkGrey) { @@ -116,7 +122,7 @@ fn Prompt<'a>(props: &'a PromptProps, _hooks: Hooks) -> impl Into>(it: &mut std::iter::Peekable, e: char) -> Option { +fn find_sgr>(it: &mut std::iter::Peekable, e: char) -> Option { let Some(c) = it.peek() else { return None; }; @@ -148,9 +154,15 @@ fn escape_chars_to_styling(content: &str) -> Vec { let (mut text, mut bold, mut underline) = (String::new(), false, false); let mut elms = Vec::new(); let mut push = |text, bold, underline| { - elms.push(MixedTextContent::new(text) - .weight(if bold { Weight::Bold } else { Weight::Normal }) - .decoration(if underline { TextDecoration::Underline } else { TextDecoration::None })); + elms.push( + MixedTextContent::new(text) + .weight(if bold { Weight::Bold } else { Weight::Normal }) + .decoration(if underline { + TextDecoration::Underline + } else { + TextDecoration::None + }), + ); }; let mut it = content.chars().peekable(); @@ -164,13 +176,19 @@ fn escape_chars_to_styling(content: &str) -> Vec { text = String::new(); match x { - 0 | 22 => {bold = false; underline = false;}, + 0 | 22 => { + bold = false; + underline = false; + } 1 => bold = true, 4 => underline = true, 24 => underline = false, - x => {dbg!(x); panic!("");}, + x => { + dbg!(x); + panic!(""); + } } - }, + } None => is_text = true, }; @@ -198,7 +216,9 @@ struct PreviewProps { #[component] fn Preview<'a>(props: &'a PreviewProps, mut hooks: Hooks) -> impl Into> { let mut contents = hooks.use_state_default(); - let width = hooks.use_component_rect().get() + let width = hooks + .use_component_rect() + .get() .map(|rect| rect.right - rect.left - 2); let update_page = hooks.use_async_handler(move |current: String| async move { @@ -207,20 +227,28 @@ fn Preview<'a>(props: &'a PreviewProps, mut hooks: Hooks) -> impl Into(props: &'a ResultsProps, mut hooks: Hooks) -> impl Into()); + let max_len = hooks.use_memo( + || props.elms.iter().map(|x| x.0.len()).max().unwrap_or(0) as u32, + &props.elms.iter().map(|x| x.0.clone()).collect::(), + ); let (width, height) = match hooks.use_component_rect().get() { Some(rect) => (rect.right - rect.left - 2, rect.bottom - rect.top - 2), - _ => (16, 16) + _ => (16, 16), }; let max_len = u32::min(max_len, height as u32); @@ -268,7 +297,7 @@ fn Results<'a>(props: &'a ResultsProps, mut hooks: Hooks) -> impl Into None, - _ => Some(props.elms[current_idx.get() as usize].0.clone()) + _ => Some(props.elms[current_idx.get() as usize].0.clone()), }; hooks.use_terminal_events({ @@ -281,22 +310,24 @@ fn Results<'a>(props: &'a ResultsProps, mut hooks: Hooks) -> impl Into { if current_idx.get() < nprops as isize - 1 { current_idx.set(current_idx.get() + 1); } else { current_idx.set(0); } - }, + } KeyCode::Enter => { current_key.as_ref().map(|current_key| { - let _ = std::process::Command::new("man").arg(¤t_key) + let _ = std::process::Command::new("man") + .arg(¤t_key) .stdout(Stdio::inherit()) .stdin(Stdio::inherit()) - .output().unwrap(); + .output() + .unwrap(); }); - }, + } _ => {} } } @@ -304,7 +335,7 @@ fn Results<'a>(props: &'a ResultsProps, mut hooks: Hooks) -> impl Into(props: &'a ResultsProps, mut hooks: Hooks) -> impl Into(_props: &'a ManPicker, mut hooks: Hooks) -> impl Into>() - , &prompt); + let elms = hooks.use_memo( + || { + pages + .iter() + .filter_map(|page| { + matches(&page.title, &prompt.read().as_str()).map(|x| (page.key.clone(), x)) + }) + .collect::>() + }, + &prompt, + ); let nelms = (elms.len(), pages.len()); - let key = hooks.use_memo(|| - elms[current_idx.get() as usize].0.clone(), - current_idx); + let key = hooks.use_memo(|| elms[current_idx.get() as usize].0.clone(), current_idx); hooks.use_terminal_events({ move |event| match event { - TerminalEvent::Key(KeyEvent { code, kind, modifiers, .. }) if kind != KeyEventKind::Release && modifiers.contains(KeyModifiers::ALT) => { + TerminalEvent::Key(KeyEvent { + code, + kind, + modifiers, + .. + }) if kind != KeyEventKind::Release && modifiers.contains(KeyModifiers::ALT) => { match code { KeyCode::Char('V') => layout.set(ManLayout::Vertical), KeyCode::Char('H') => layout.set(ManLayout::Horizontal), - _ => {}, + _ => {} } - }, - _ => {}, - }}); + } + _ => {} + } + }); if *layout.read() == ManLayout::Vertical { element! { @@ -390,7 +430,6 @@ fn Picker<'a>(_props: &'a ManPicker, mut hooks: Hooks) -> impl Into String { - let title_len = title.chars().count(); - - // If inner is longer, clip it - let (clipped_title, clipped_len) = if title_len > length { - (title.chars().take(length).collect(), length) - } else { - (title.to_string(), title_len) - }; - - // Calculate padding - let total_padding = length.saturating_sub(clipped_len); - let left_padding = total_padding / 2; - let right_padding = total_padding - left_padding; - - format!( - "{}{}{}", - border_char.repeat(left_padding), - clipped_title, - border_char.repeat(right_padding) - ) -} - /// The props which can be passed to the [`View`] component. #[non_exhaustive] #[with_layout_style_props] @@ -189,9 +152,6 @@ pub struct ViewProps<'a> { /// The edges to render the border on. By default, the border will be rendered on all edges. pub border_edges: Option, - /// The color of the border. - pub border_title: Option, - /// The color of the background. pub background_color: Option, } @@ -216,7 +176,6 @@ pub struct View { border_text_style: CanvasTextStyle, border_edges: Edges, background_color: Option, - border_title: Option, } impl Component for View { @@ -239,8 +198,6 @@ impl Component for View { }; self.border_edges = props.border_edges.unwrap_or(Edges::all()); self.background_color = props.background_color; - self.border_title = props.border_title.clone(); - let mut style: taffy::style::Style = props.layout_style().into(); style.border = if self.border_style.is_none() { Rect::zero() @@ -320,16 +277,10 @@ impl Component for View { canvas.set_text(0, 0, &border.top_left.to_string(), self.border_text_style); } - let (top_size, top_char) = ( - layout.size.width as usize - left_border_size - right_border_size, - border.top.to_string(), - ); - let top = match self.border_title { - Some(ref title) if title.pos == BorderTitlePos::Top => - center_and_clip(&top_char, top_size, &title.title), - _ => top_char.repeat(top_size), - }; - + let top = border + .top + .to_string() + .repeat(layout.size.width as usize - left_border_size - right_border_size); canvas.set_text(left_border_size as _, 0, &top, self.border_text_style); if self.border_edges.contains(Edges::Right) { @@ -366,16 +317,10 @@ impl Component for View { ); } - let (bottom_size, bottom_char) = ( - layout.size.width as usize - left_border_size - right_border_size, - border.bottom.to_string(), - ); - let bottom = match self.border_title { - Some(ref title) if title.pos == BorderTitlePos::Bottom => - center_and_clip(&bottom_char, bottom_size, &title.title), - _ => bottom_char.repeat(bottom_size), - }; - + let bottom = border + .bottom + .to_string() + .repeat(layout.size.width as usize - left_border_size - right_border_size); canvas.set_text( left_border_size as _, layout.size.height as isize - 1, From accb8ac1d55f722f16860a98bc63d29e1f02b922 Mon Sep 17 00:00:00 2001 From: Lorenz Schmidt Date: Wed, 25 Feb 2026 19:13:22 +0000 Subject: [PATCH 10/12] fix(manpicker): avoid invalid indices when changing prompt --- examples/picker.rs | 73 ++++++++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 35 deletions(-) diff --git a/examples/picker.rs b/examples/picker.rs index 91f36b0..b6dfad1 100644 --- a/examples/picker.rs +++ b/examples/picker.rs @@ -91,7 +91,6 @@ fn get_man_pages() -> io::Result> { #[derive(Props, Default)] struct PromptProps { - title: String, show_carrot: bool, prompt: Option>, nelms: (usize, usize), @@ -165,7 +164,7 @@ fn escape_chars_to_styling(content: &str) -> Vec { ); }; - let mut it = content.chars().peekable(); + let mut it = content.chars().take(4096).peekable(); loop { let mut is_text = false; let sgr = find_sgr(&mut it, 'm'); @@ -183,9 +182,9 @@ fn escape_chars_to_styling(content: &str) -> Vec { 1 => bold = true, 4 => underline = true, 24 => underline = false, - x => { - dbg!(x); - panic!(""); + _ => { + //dbg!(x); + //panic!(""); } } } @@ -272,20 +271,11 @@ fn Results<'a>(props: &'a ResultsProps, mut hooks: Hooks) -> impl Into(), - ); - let (width, height) = match hooks.use_component_rect().get() { Some(rect) => (rect.right - rect.left - 2, rect.bottom - rect.top - 2), - _ => (16, 16), + _ => (30, 20), }; - let max_len = u32::min(max_len, height as u32); - let header_len = u32::max(width as u32 - max_len - 4, 4); - let key_len = width as u32 - header_len; - let mut beginning = hooks.use_state(|| 0); if beginning > current_idx.get() { @@ -294,30 +284,35 @@ fn Results<'a>(props: &'a ResultsProps, mut hooks: Hooks) -> impl Into(), &beginning) + ); + + let max_len = u32::min(max_len, width as u32); + let header_len = u32::max(width as u32 - max_len - 1, 4); + let key_len = width as u32 - header_len; + let nprops = props.elms.len(); let current_key = match props.elms.len() { 0 => None, - _ => Some(props.elms[current_idx.get() as usize].0.clone()), + _ => { + if current_idx.get() as usize >= props.elms.len() { + current_idx.set(props.elms.len() as isize - 1); + } + + Some(props.elms[current_idx.get() as usize].0.clone()) + } }; hooks.use_terminal_events({ move |event| match event { - TerminalEvent::Key(KeyEvent { code, kind, .. }) if kind != KeyEventKind::Release => { + TerminalEvent::Key(KeyEvent { code, kind, modifiers, .. }) if kind != KeyEventKind::Release => { match code { - KeyCode::Up => { - if current_idx.get() - 1 < 0 { - current_idx.set(nprops as isize - 1); - } else { - current_idx.set(current_idx.get() - 1); - } - } - KeyCode::Down => { - if current_idx.get() < nprops as isize - 1 { - current_idx.set(current_idx.get() + 1); - } else { - current_idx.set(0); - } - } + KeyCode::Up => current_idx.set((current_idx.get() - 1) % nprops as isize), + KeyCode::Char('u') if modifiers.contains(KeyModifiers::CONTROL) => current_idx.set((current_idx.get() - 10) % nprops as isize), + KeyCode::Down => current_idx.set((current_idx.get() + 1) % nprops as isize), + KeyCode::Char('d') if modifiers.contains(KeyModifiers::CONTROL) => current_idx.set((current_idx.get() + 10) % nprops as isize), KeyCode::Enter => { current_key.as_ref().map(|current_key| { let _ = std::process::Command::new("man") @@ -389,7 +384,13 @@ fn Picker<'a>(_props: &'a ManPicker, mut hooks: Hooks) -> impl Into= elms.len() { + elms.last().map(|x| x.0.clone()).unwrap_or(String::new()) + } else { + elms[current_idx.get() as usize].0.clone() + } + }, (¤t_idx, &prompt)); hooks.use_terminal_events({ move |event| match event { @@ -414,7 +415,7 @@ fn Picker<'a>(_props: &'a ManPicker, mut hooks: Hooks) -> impl Into(_props: &'a ManPicker, mut hooks: Hooks) -> impl Into Date: Thu, 26 Feb 2026 13:32:50 +0000 Subject: [PATCH 11/12] fix(manpicker): use actual modulo index after scrolling up --- examples/picker.rs | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/examples/picker.rs b/examples/picker.rs index b6dfad1..44991cc 100644 --- a/examples/picker.rs +++ b/examples/picker.rs @@ -140,7 +140,7 @@ fn find_sgr>(it: &mut std::iter::Peekable, e: char) return None; }; - if *it.peek().unwrap() == e { + if it.peek().map(|x| *x == e).unwrap_or(true) { return Some(0); } @@ -262,7 +262,7 @@ fn Preview<'a>(props: &'a PreviewProps, mut hooks: Hooks) -> impl Into)>, - current_idx: Option>, + current_idx: Option>, } #[component] @@ -280,8 +280,8 @@ fn Results<'a>(props: &'a ResultsProps, mut hooks: Hooks) -> impl Into current_idx.get() { beginning.set(current_idx.get()); - } else if current_idx.get() > beginning + height as isize - 1 { - beginning.set(current_idx.get() - height as isize + 1); + } else if current_idx.get() > beginning + height as usize - 1 { + beginning.set(current_idx.get() - height as usize + 1); } let max_len = hooks.use_memo( @@ -297,22 +297,25 @@ fn Results<'a>(props: &'a ResultsProps, mut hooks: Hooks) -> impl Into None, _ => { - if current_idx.get() as usize >= props.elms.len() { - current_idx.set(props.elms.len() as isize - 1); + if current_idx.get() >= props.elms.len() { + current_idx.set(props.elms.len() - 1); } - Some(props.elms[current_idx.get() as usize].0.clone()) + Some(props.elms[current_idx.get()].0.clone()) } }; + let (stdout, stderr) = hooks.use_output(); + hooks.use_terminal_events({ move |event| match event { TerminalEvent::Key(KeyEvent { code, kind, modifiers, .. }) if kind != KeyEventKind::Release => { match code { - KeyCode::Up => current_idx.set((current_idx.get() - 1) % nprops as isize), - KeyCode::Char('u') if modifiers.contains(KeyModifiers::CONTROL) => current_idx.set((current_idx.get() - 10) % nprops as isize), - KeyCode::Down => current_idx.set((current_idx.get() + 1) % nprops as isize), - KeyCode::Char('d') if modifiers.contains(KeyModifiers::CONTROL) => current_idx.set((current_idx.get() + 10) % nprops as isize), + KeyCode::Up => + current_idx.set((current_idx.get() as isize - 1).rem_euclid(nprops as isize) as usize), + KeyCode::Char('u') if modifiers.contains(KeyModifiers::CONTROL) => current_idx.set((current_idx.get() as isize - 10).rem_euclid(nprops as isize) as usize), + KeyCode::Down => current_idx.set((current_idx.get() + 1) % nprops ), + KeyCode::Char('d') if modifiers.contains(KeyModifiers::CONTROL) => current_idx.set((current_idx.get() + 10) % nprops), KeyCode::Enter => { current_key.as_ref().map(|current_key| { let _ = std::process::Command::new("man") @@ -337,7 +340,7 @@ fn Results<'a>(props: &'a ResultsProps, mut hooks: Hooks) -> impl Into(_props: &'a ManPicker, mut hooks: Hooks) -> impl Into = hooks.use_state_default(); - let current_idx = hooks.use_state(|| 0isize); + let current_idx = hooks.use_state(|| 0usize); // layout can be changed during runtime let mut layout = hooks.use_state(|| ManLayout::Vertical); @@ -385,10 +388,10 @@ fn Picker<'a>(_props: &'a ManPicker, mut hooks: Hooks) -> impl Into= elms.len() { + if current_idx.get() >= elms.len() { elms.last().map(|x| x.0.clone()).unwrap_or(String::new()) } else { - elms[current_idx.get() as usize].0.clone() + elms[current_idx.get()].0.clone() } }, (¤t_idx, &prompt)); From 88c698b13b1c9891b142f90a6313b60421c84aaf Mon Sep 17 00:00:00 2001 From: Lorenz Schmidt Date: Sun, 1 Mar 2026 09:03:05 +0000 Subject: [PATCH 12/12] fix(manpicker): filter out entries with empty titles --- examples/picker.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/examples/picker.rs b/examples/picker.rs index 44991cc..ce62a41 100644 --- a/examples/picker.rs +++ b/examples/picker.rs @@ -27,12 +27,17 @@ fn parse_man_output(output: &str) -> Vec { } // The key is the first part (e.g., "arandr") - let key = parts[0].trim_end_matches('(').to_string(); + //let key = parts[0].trim_end_matches('(').to_string(); + let key = parts[0].split('(').next().unwrap().to_string(); // The title is the rest of the line after the key and section (e.g., "visual front end for XRandR 1.2") let title_start = line.find(" - ").map_or(line.len(), |pos| pos + 3); let title = line[title_start..].trim().to_string(); + if title.is_empty() { + continue; + } + man_pages.push(ManPage { title, key }); } @@ -305,8 +310,7 @@ fn Results<'a>(props: &'a ResultsProps, mut hooks: Hooks) -> impl Into {