Skip to main content

tui/components/
bordered_text_field.rs

1use crate::components::text_field::TextField;
2use crate::components::{Component, Event, ViewContext};
3use crate::line::Line;
4use crate::rendering::frame::Frame;
5use crate::rendering::soft_wrap::{display_width_text, truncate_line};
6use crate::style::Style;
7
8/// Fixed columns consumed by the two side borders: `│` + space on each side.
9const HORIZONTAL_PADDING: usize = 4;
10/// Fixed columns in the top border besides the label: `┌─ ` + ` ┐` (5 chars).
11const TOP_BORDER_FIXED_COLS: usize = 5;
12
13/// Single-line text input rendered inside a box with the label intersecting the top border.
14///
15/// ```text
16/// ┌─ Name ─────────────────────────┐
17/// │ my-agent▏                      │
18/// └────────────────────────────────┘
19/// ```
20pub struct BorderedTextField {
21    pub inner: TextField,
22    label: String,
23    width: usize,
24    placeholder: Option<String>,
25}
26
27impl BorderedTextField {
28    pub fn new(label: impl Into<String>, value: String) -> Self {
29        Self { inner: TextField::new(value), label: label.into(), width: 0, placeholder: None }
30    }
31
32    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
33        self.placeholder = Some(placeholder.into());
34        self
35    }
36
37    pub fn set_width(&mut self, width: usize) {
38        self.width = width;
39        self.inner.set_content_width(width.saturating_sub(HORIZONTAL_PADDING).max(1));
40    }
41
42    pub fn set_value(&mut self, value: String) {
43        self.inner.set_value(value);
44    }
45
46    pub fn value(&self) -> &str {
47        &self.inner.value
48    }
49
50    pub fn clear(&mut self) {
51        self.inner.clear();
52    }
53
54    pub fn to_json(&self) -> serde_json::Value {
55        self.inner.to_json()
56    }
57
58    pub fn render_field(&self, context: &ViewContext, focused: bool) -> Vec<Line> {
59        let width = self.width.max(self.min_width());
60        let glyphs = BorderGlyphs::for_focus(focused);
61        let border_color = if focused { context.theme.primary() } else { context.theme.text_secondary() };
62        let border_style = Style::fg(border_color);
63        let label_style = Style::fg(context.theme.text_primary());
64
65        vec![
66            self.top_border(width, glyphs, border_style, label_style),
67            self.middle_row(width, glyphs, border_style, context, focused),
68            Self::bottom_border(width, glyphs, border_style),
69        ]
70    }
71
72    fn min_width(&self) -> usize {
73        TOP_BORDER_FIXED_COLS + 1 + display_width_text(&self.label)
74    }
75
76    fn top_border(&self, width: usize, glyphs: BorderGlyphs, border_style: Style, label_style: Style) -> Line {
77        let label_cols = display_width_text(&self.label);
78        let dash_cols = width.saturating_sub(label_cols + TOP_BORDER_FIXED_COLS);
79
80        let mut line = Line::default();
81        line.push_with_style(format!("{}{} ", glyphs.top_left, glyphs.horizontal), border_style);
82        line.push_with_style(self.label.clone(), label_style);
83        line.push_with_style(" ", border_style);
84        line.push_with_style(glyphs.horizontal.repeat(dash_cols), border_style);
85        line.push_with_style(glyphs.top_right, border_style);
86        line
87    }
88
89    fn middle_row(
90        &self,
91        width: usize,
92        glyphs: BorderGlyphs,
93        border_style: Style,
94        context: &ViewContext,
95        focused: bool,
96    ) -> Line {
97        let content_width = width.saturating_sub(HORIZONTAL_PADDING);
98        let inner_line = self.placeholder.as_ref().filter(|_| self.inner.value.is_empty()).map_or_else(
99            || self.inner.render_field(context, focused).into_iter().next().unwrap_or_default(),
100            |placeholder| Line::with_style(placeholder.clone(), Style::fg(context.theme.muted())),
101        );
102        let clipped = truncate_line(&inner_line, content_width);
103
104        let mut row = Line::default();
105        row.push_with_style(format!("{} ", glyphs.vertical), border_style);
106        row.append_line(&clipped);
107        row.extend_bg_to_width(width.saturating_sub(2));
108        row.push_with_style(format!(" {}", glyphs.vertical), border_style);
109        row
110    }
111
112    fn bottom_border(width: usize, glyphs: BorderGlyphs, border_style: Style) -> Line {
113        let inner_dashes = width.saturating_sub(2);
114        let mut line = Line::default();
115        line.push_with_style(glyphs.bottom_left, border_style);
116        line.push_with_style(glyphs.horizontal.repeat(inner_dashes), border_style);
117        line.push_with_style(glyphs.bottom_right, border_style);
118        line
119    }
120}
121
122#[derive(Clone, Copy)]
123struct BorderGlyphs {
124    top_left: &'static str,
125    top_right: &'static str,
126    bottom_left: &'static str,
127    bottom_right: &'static str,
128    horizontal: &'static str,
129    vertical: &'static str,
130}
131
132impl BorderGlyphs {
133    const LIGHT: Self =
134        Self {
135            top_left: "┌", top_right: "┐", bottom_left: "└", bottom_right: "┘", horizontal: "─", vertical: "│"
136        };
137    const HEAVY: Self =
138        Self {
139            top_left: "┏", top_right: "┓", bottom_left: "┗", bottom_right: "┛", horizontal: "━", vertical: "┃"
140        };
141
142    fn for_focus(focused: bool) -> Self {
143        if focused { Self::HEAVY } else { Self::LIGHT }
144    }
145}
146
147impl Component for BorderedTextField {
148    type Message = ();
149
150    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
151        self.inner.on_event(event).await
152    }
153
154    fn render(&mut self, context: &ViewContext) -> Frame {
155        Frame::new(self.render_field(context, true))
156    }
157}