From 8cd971c41c43c925df5cfeac33f6db22c4938168 Mon Sep 17 00:00:00 2001 From: Lucas Pickering Date: Mon, 2 Dec 2024 12:50:30 -0500 Subject: [PATCH] Add separate text box placeholder for focus --- crates/tui/src/view/common/text_box.rs | 59 ++++++++++++++++++- .../tui/src/view/component/queryable_body.rs | 14 +++-- crates/tui/src/view/test_util.rs | 22 ++++++- 3 files changed, 86 insertions(+), 9 deletions(-) diff --git a/crates/tui/src/view/common/text_box.rs b/crates/tui/src/view/common/text_box.rs index 1f4a5db3..2c73701e 100644 --- a/crates/tui/src/view/common/text_box.rs +++ b/crates/tui/src/view/common/text_box.rs @@ -29,7 +29,11 @@ const DEBOUNCE: Duration = Duration::from_millis(500); pub struct TextBox { // Parameters sensitive: bool, + /// Text to show when text content is empty placeholder_text: String, + /// Text to show when text content is empty and text box is in focus. If + /// `None`, the default placeholder will be shown instead. + placeholder_focused: Option, /// Predicate function to apply visual validation effect #[debug(skip)] validator: Option, @@ -77,6 +81,16 @@ impl TextBox { self } + /// Set placeholder text to show only while the text box is focused. If not + /// set, this will fallback to the general placeholder text. + pub fn placeholder_focused( + mut self, + placeholder: impl Into, + ) -> Self { + self.placeholder_focused = Some(placeholder.into()); + self + } + /// Set validation function. If input is invalid, the `on_change` and /// `on_submit` callbacks will be blocked, meaning the user must fix the /// error or cancel. @@ -87,6 +101,7 @@ impl TextBox { self.validator = Some(Box::new(validator)); self } + /// Set the callback to be called when the user clicks the textbox pub fn on_click(mut self, f: impl 'static + Fn()) -> Self { self.on_click = Some(Box::new(f)); @@ -261,12 +276,20 @@ impl Draw for TextBox { fn draw(&self, frame: &mut Frame, _: (), metadata: DrawMetadata) { let styles = &TuiContext::get().styles; - // Hide top secret data let text: Text = if self.state.text.is_empty() { - Line::from(self.placeholder_text.as_str()) + // Users can optionally set a different placeholder for when focused + let placeholder = if metadata.has_focus() { + self.placeholder_focused + .as_deref() + .unwrap_or(&self.placeholder_text) + } else { + &self.placeholder_text + }; + Line::from(placeholder) .style(styles.text_box.placeholder) .into() } else if self.sensitive { + // Hide top secret data Masked::new(&self.state.text, '•').into() } else { self.state.text.as_str().into() @@ -646,6 +669,38 @@ mod tests { ]]); } + #[rstest] + fn test_placeholder_focused( + harness: TestHarness, + #[with(9, 1)] terminal: TestTerminal, + ) { + let mut component = TestComponent::new( + &harness, + &terminal, + TextBox::default() + .placeholder("unfocused") + .placeholder_focused("focused"), + (), + ); + let styles = &TuiContext::get().styles.text_box; + + // Focused + assert_state(&component.data().state, "", 0); + terminal.assert_buffer_lines([vec![ + cursor("f"), + Span::styled("ocused", styles.text.patch(styles.placeholder)), + text(" "), + ]]); + + // Unfocused + component.unfocus(); + component.drain_draw().assert_empty(); + terminal.assert_buffer_lines([vec![Span::styled( + "unfocused", + styles.text.patch(styles.placeholder), + )]]); + } + #[rstest] fn test_validator( harness: TestHarness, diff --git a/crates/tui/src/view/component/queryable_body.rs b/crates/tui/src/view/component/queryable_body.rs index 928c2b17..33ca84db 100644 --- a/crates/tui/src/view/component/queryable_body.rs +++ b/crates/tui/src/view/component/queryable_body.rs @@ -90,7 +90,8 @@ impl QueryableBody { move || ViewContext::push_event(Event::new_local(callback)) }; let text_box = TextBox::default() - .placeholder(format!("'{binding}' to filter body with JSONPath")) + .placeholder(format!("{binding} to query body")) + .placeholder_focused("Query with JSONPath (ex: $.results)") .validator(|text| JsonPath::parse(text).is_ok()) // Callback trigger an events, so we can modify our own state .on_click(send_local(QueryCallback::Focus)) @@ -429,10 +430,13 @@ mod tests { vec![gutter("2"), " \"greeting\": \"hello\"".into()], vec![gutter("3"), " } ".into()], vec![gutter(" "), " ".into()], - vec![Span::styled( - "'/' to filter body with JSONPath", - styles.text.patch(styles.placeholder), - )], + vec![ + Span::styled( + "/ to query body", + styles.text.patch(styles.placeholder), + ), + Span::styled(" ", styles.text), + ], ]); // Type something into the query box diff --git a/crates/tui/src/view/test_util.rs b/crates/tui/src/view/test_util.rs index 1c249fd6..7c63ff9f 100644 --- a/crates/tui/src/view/test_util.rs +++ b/crates/tui/src/view/test_util.rs @@ -40,6 +40,9 @@ pub struct TestComponent<'term, T, Props> { /// components since props typically just contain identifiers, references, /// and primitives. Modify using [Self::set_props]. props: Props, + /// Should the component be given focus on the next draw? Defaults to + /// `true` + has_focus: bool, } impl<'term, Props, T> TestComponent<'term, T, Props> @@ -68,6 +71,7 @@ where area: terminal.area(), component, props: initial_props, + has_focus: true, }; // Do an initial draw to set up state, then handle any triggered events slf.draw(); @@ -88,6 +92,16 @@ where self.props = props; } + /// Enable focus for the next draw + pub fn focus(&mut self) { + self.has_focus = true; + } + + /// Disable focus for the next draw + pub fn unfocus(&mut self) { + self.has_focus = false; + } + /// Get a reference to the wrapped component's inner data pub fn data(&self) -> &T { self.component.data() @@ -103,8 +117,12 @@ where /// use the same props from the last draw. fn draw(&mut self) { self.terminal.draw(|frame| { - self.component - .draw(frame, self.props.clone(), self.area, true) + self.component.draw( + frame, + self.props.clone(), + self.area, + self.has_focus, + ) }); }