Skip to main content

egui_components/
input.rs

1//! gpui-component-style single-line `Input` field.
2//!
3//! Wraps [`egui::TextEdit::singleline`] with theme-aware framing (border,
4//! focus ring, padding, optional placeholder + leading/trailing label).
5
6use crate::common::Size;
7use egui::{vec2, FontId, Response, Sense, Stroke, Ui, Widget};
8use egui_components_theme::{mix, Theme};
9
10pub struct Input<'a> {
11    value: &'a mut String,
12    placeholder: Option<String>,
13    width: Option<f32>,
14    password: bool,
15    disabled: bool,
16    size: Size,
17}
18
19impl<'a> Input<'a> {
20    pub fn new(value: &'a mut String) -> Self {
21        Self {
22            value,
23            placeholder: None,
24            width: None,
25            password: false,
26            disabled: false,
27            size: Size::Medium,
28        }
29    }
30    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
31        self.placeholder = Some(p.into());
32        self
33    }
34    pub fn width(mut self, w: f32) -> Self {
35        self.width = Some(w);
36        self
37    }
38    pub fn password(mut self, p: bool) -> Self {
39        self.password = p;
40        self
41    }
42    pub fn disabled(mut self, d: bool) -> Self {
43        self.disabled = d;
44        self
45    }
46    pub fn size(mut self, s: Size) -> Self {
47        self.size = s;
48        self
49    }
50    pub fn small(self) -> Self {
51        self.size(Size::Small)
52    }
53    pub fn large(self) -> Self {
54        self.size(Size::Large)
55    }
56}
57
58impl<'a> Widget for Input<'a> {
59    fn ui(self, ui: &mut Ui) -> Response {
60        let theme = Theme::get(ui.ctx());
61        let m = theme.metrics;
62        let c = theme.colors;
63        let height = self.size.input_height(&m);
64        let width = self.width.unwrap_or_else(|| ui.available_width().min(240.0));
65        let desired = vec2(width, height);
66
67        // Reserve the frame, then place the TextEdit inside.
68        let (rect, response) = ui.allocate_exact_size(desired, Sense::hover());
69
70        let painter = ui.painter();
71        let radius = theme.corner();
72
73        let bg = if self.disabled {
74            mix(c.background, c.muted_background, 0.6)
75        } else {
76            c.background
77        };
78        painter.rect_filled(rect, radius, bg);
79
80        // Border (will be re-drawn focused below).
81        let mut border_color = c.input_border;
82        let mut has_focus = false;
83
84        // Place the TextEdit area inside the frame.
85        let inner_rect = rect.shrink2(vec2(m.input_padding_x, 4.0));
86        let inner_response = {
87            let mut child = ui.new_child(
88                egui::UiBuilder::new()
89                    .max_rect(inner_rect)
90                    .layout(egui::Layout::left_to_right(egui::Align::Center)),
91            );
92            if self.disabled {
93                child.disable();
94            }
95            let edit = egui::TextEdit::singleline(self.value)
96                .frame(egui::Frame::NONE)
97                .desired_width(inner_rect.width())
98                .password(self.password)
99                .font(FontId::proportional(m.font_size_md))
100                .text_color(if self.disabled {
101                    mix(c.foreground, c.muted_foreground, 0.5)
102                } else {
103                    c.foreground
104                });
105            let r = child.add(edit);
106            if r.has_focus() {
107                has_focus = true;
108            }
109            r
110        };
111
112        if has_focus {
113            border_color = c.ring;
114        } else if response.hovered() || inner_response.hovered() {
115            border_color = mix(c.input_border, c.foreground, 0.25);
116        }
117
118        ui.painter().rect_stroke(
119            rect,
120            radius,
121            Stroke::new(m.border_width, border_color),
122            egui::StrokeKind::Inside,
123        );
124
125        if has_focus {
126            ui.painter().rect_stroke(
127                rect.expand(2.0),
128                theme.corner(),
129                theme.focus_ring(),
130                egui::StrokeKind::Outside,
131            );
132        }
133
134        // Placeholder
135        if self.value.is_empty() && !has_focus {
136            if let Some(ph) = &self.placeholder {
137                let font = FontId::proportional(m.font_size_md);
138                ui.painter().text(
139                    egui::pos2(inner_rect.left(), inner_rect.center().y),
140                    egui::Align2::LEFT_CENTER,
141                    ph,
142                    font,
143                    c.muted_foreground,
144                );
145            }
146        }
147
148        if !self.disabled && (response.hovered() || inner_response.hovered()) {
149            ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
150        }
151
152        // Return the inner response so callers see `.changed()`, `.lost_focus()` etc.
153        inner_response.union(response)
154    }
155}