GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(widget): migrate TextBox (Phase 5q)
Widest-surface leaf so far. New adapter surface: Input clipboard quintet
(cut/copy/paste/select-all/clear — defaults replicate the whole-value
Element defaults), Paint::prepare_text (glyph shaping is load-bearing for
cursor<->pixel mapping), Layout::hit_row_rect (restores the legacy row-hit
geometry the earlier migrations dropped — cce-files' save-name box needs
it), Layout::adjust_row_rect + rect_assigned (width clamp on both rect
paths; ungated scroll re-clamp), Input::tracks_base_focus (legacy TextBox
never set base.focused), and Paint::legacy_focus_highlight — the legacy
shared focus-highlight overlay, suppressed for all migrated widgets, is
re-enabled per-widget: legacy TextBox kept the Element default and the
focused editor's primary-tint wash (data-editor's teal editing surface)
is real behavior, found when the first A/B came back 1.4M pixels apart.
Render split preserved faithfully (non-rounded: full-width bg + disabled
branch; rounded: side-label inset, no disabled branch). Flagged
approximations: release containment re-checked against the plain rect
(legacy hit-gated releases through the row-substituted test), wheel now
hit-gated by the adapter.
In-crate sweep: treelist, scrolling_list, keybinds_control,
multi_control (InstancedWidget variant), parameters_bg.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011vTEhEwTGYYhHRbh658SAc
src/widget/container/parameters_bg.rs | 2 +-
src/widget/container/scrolling_list.rs | 6 +-
src/widget/container/treelist.rs | 18 +-
src/widget/input/keybinds_control.rs | 4 +-
src/widget/input/multi_control.rs | 4 +-
src/widget/input/text_box.rs | 1756 ++++++++++++++++----------------
src/widget/model.rs | 168 ++-
7 files changed, 1037 insertions(+), 921 deletions(-)
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 4f09912..414a913 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -15,7 +15,7 @@ pub struct ParametersBg {
pub spinboxes: Vec<Option<crate::widget::Adapted<Spinbox>>>,
pub buttons: Vec<Option<crate::widget::Adapted<Button>>>,
pub choices: Vec<Option<crate::widget::Adapted<Dropdown>>>,
- pub texts: Vec<Option<TextBox>>,
+ pub texts: Vec<Option<crate::widget::Adapted<TextBox>>>,
pub checkboxes: Vec<Option<crate::widget::Adapted<Checkbox>>>,
pub colors: Vec<Option<ColorSelector>>,
visible: bool,
diff --git a/src/widget/container/scrolling_list.rs b/src/widget/container/scrolling_list.rs
index 64b586e..d5e0a6c 100644
--- a/src/widget/container/scrolling_list.rs
+++ b/src/widget/container/scrolling_list.rs
@@ -40,7 +40,7 @@ pub struct List {
pub last_click_time: Option<std::time::Instant>,
pub search_enabled: bool,
pub search_visible: bool,
- pub search_box: TextBox,
+ pub search_box: crate::widget::Adapted<TextBox>,
}
impl List {
@@ -690,7 +690,7 @@ impl Element for List {
let self_ptr = self as *mut Self;
let self_id = self.base.id();
unsafe {
- let sb_ptr = &mut (*self_ptr).search_box as *mut TextBox as *mut (dyn Element + 'static);
+ let sb_ptr = (*self_ptr).search_box.as_ptr_mut();
let sb_id = (*self_ptr).search_box.base().unwrap().id();
ctx.register_widget(sb_id, sb_ptr);
ctx.link_ids(self_id, sb_id);
@@ -703,7 +703,7 @@ impl Element for List {
if self.search_enabled && self.search_visible {
let self_ptr = self as *const Self as *mut Self;
unsafe {
- list.push(&mut (*self_ptr).search_box as *mut TextBox as *mut (dyn Element + 'static));
+ list.push((*self_ptr).search_box.as_ptr_mut());
}
}
list
diff --git a/src/widget/container/treelist.rs b/src/widget/container/treelist.rs
index ce18734..7c5574c 100644
--- a/src/widget/container/treelist.rs
+++ b/src/widget/container/treelist.rs
@@ -160,10 +160,10 @@ fn build_tree(
pub struct TreeList {
pub base: Widget,
pub scroll_box: ScrollBox,
- pub search_box: TextBox,
+ pub search_box: crate::widget::Adapted<TextBox>,
pub add_key_btn: crate::widget::Adapted<Button>,
pub add_key_popover_open: bool,
- pub add_key_popover_box: TextBox,
+ pub add_key_popover_box: crate::widget::Adapted<TextBox>,
pub new_key_path_request: Option<String>,
pub flat_keys: Vec<(String, serde_json::Value)>,
pub annotations: Vec<Option<String>>,
@@ -179,7 +179,7 @@ pub struct TreeList {
pub last_scroll_y: f32,
pub scrollbar_activity_timer: f32,
pub deleted_key_path: Option<String>,
- pub edit_box: TextBox,
+ pub edit_box: crate::widget::Adapted<TextBox>,
pub editing_key_idx: Option<usize>,
pub double_click_timer: Option<(std::time::Instant, usize)>,
pub rename_request: Option<(String, String)>,
@@ -540,7 +540,7 @@ impl Element for TreeList {
let self_ptr = self as *mut Self;
let self_id = self.base.id();
unsafe {
- let eb_ptr = &mut (*self_ptr).edit_box as *mut TextBox as *mut (dyn Element + 'static);
+ let eb_ptr = (*self_ptr).edit_box.as_ptr_mut();
let eb_id = (*self_ptr).edit_box.base().unwrap().id();
ctx.register_widget(eb_id, eb_ptr);
ctx.link_ids(self_id, eb_id);
@@ -1159,7 +1159,7 @@ impl Element for TreeList {
let self_ptr = self as *mut Self;
let self_id = self.base.id();
unsafe {
- let sb_ptr = &mut (*self_ptr).search_box as *mut TextBox as *mut (dyn Element + 'static);
+ let sb_ptr = (*self_ptr).search_box.as_ptr_mut();
let sb_id = (*self_ptr).search_box.base().unwrap().id();
ctx.register_widget(sb_id, sb_ptr);
ctx.link_ids(self_id, sb_id);
@@ -1171,7 +1171,7 @@ impl Element for TreeList {
ctx.link_ids(self_id, btn_id);
(*btn_ptr).set_parent(Some(self_ptr), ctx);
- let pop_ptr = &mut (*self_ptr).add_key_popover_box as *mut TextBox as *mut (dyn Element + 'static);
+ let pop_ptr = (*self_ptr).add_key_popover_box.as_ptr_mut();
let pop_id = (*self_ptr).add_key_popover_box.base().unwrap().id();
ctx.register_widget(pop_id, pop_ptr);
ctx.link_ids(self_id, pop_id);
@@ -1184,13 +1184,13 @@ impl Element for TreeList {
let mut list = self.children.clone();
let self_ptr = self as *const Self as *mut Self;
unsafe {
- list.push(&mut (*self_ptr).search_box as *mut TextBox as *mut (dyn Element + 'static));
+ list.push((*self_ptr).search_box.as_ptr_mut());
list.push((*self_ptr).add_key_btn.as_ptr_mut());
if (*self_ptr).add_key_popover_open {
- list.push(&mut (*self_ptr).add_key_popover_box as *mut TextBox as *mut (dyn Element + 'static));
+ list.push((*self_ptr).add_key_popover_box.as_ptr_mut());
}
if (*self_ptr).editing_key_idx.is_some() {
- list.push(&mut (*self_ptr).edit_box as *mut TextBox as *mut (dyn Element + 'static));
+ list.push((*self_ptr).edit_box.as_ptr_mut());
}
}
list
diff --git a/src/widget/input/keybinds_control.rs b/src/widget/input/keybinds_control.rs
index 3693c25..79bae98 100644
--- a/src/widget/input/keybinds_control.rs
+++ b/src/widget/input/keybinds_control.rs
@@ -6,8 +6,8 @@ use crate::widget::TextLabel;
#[derive(Clone, Debug)]
pub struct KeybindRow {
- pub key_input: TextBox,
- pub cmd_input: TextBox,
+ pub key_input: crate::widget::Adapted<TextBox>,
+ pub cmd_input: crate::widget::Adapted<TextBox>,
pub remove_button: Adapted<Button>,
}
diff --git a/src/widget/input/multi_control.rs b/src/widget/input/multi_control.rs
index deb86cb..66c339b 100644
--- a/src/widget/input/multi_control.rs
+++ b/src/widget/input/multi_control.rs
@@ -12,7 +12,7 @@ pub struct InstancedControl {
#[derive(Clone, Debug)]
pub enum InstancedWidget {
- TextBox(TextBox),
+ TextBox(Adapted<TextBox>),
Spinbox(Adapted<Spinbox>),
Toggle(Adapted<Toggle>),
Slider(Adapted<Slider>),
@@ -130,7 +130,7 @@ impl InstancedWidget {
#[derive(Clone, Debug)]
pub struct MultiControlRow {
- pub key_input: TextBox,
+ pub key_input: Adapted<TextBox>,
pub type_dropdown: Adapted<Dropdown>,
pub value_widget: InstancedWidget,
pub remove_button: Adapted<Button>,
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 7dfb674..6f32092 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -1,4 +1,27 @@
+//! Narrow-trait `TextBox` (Phase 5q). The widest-surface leaf so far: real selection-aware
+//! clipboard (the new `Input` cut/copy/paste/select-all/clear hooks — their defaults replicate
+//! the whole-value `Element` defaults for everyone else), load-bearing glyph shaping through
+//! `Paint::prepare_text` (cursor↔pixel mapping reads the measured advances), the row-hit
+//! restoration (`Layout::hit_row_rect` — cce-files' save-name box relies on row hits), a
+//! width/max-width clamp on both rect paths (`Layout::adjust_rect` + `adjust_row_rect`), the
+//! ungated `Layout::rect_assigned` (scroll re-clamp on every `set_rect`, hidden or not), and
+//! `Input::tracks_base_focus = false` (legacy `focus()` never set the base flag — the detached
+//! label must not color as focused).
+//!
+//! Parity notes:
+//! - The legacy render split is asymmetric and preserved faithfully: the non-rounded path
+//! (`extra_quads`) draws at the full base x/width with a disabled special-case; the rounded
+//! path (`all_rounded_quads`) insets by the side label and has NO disabled branch.
+//! - Releases: legacy `mouse_input` hit-gated releases too (out-of-rect releases were dropped).
+//! The adapter delivers releases ungated, so the model re-checks containment itself against
+//! the plain rect (the row-substituted release geometry is approximated — flagged).
+//! - Wheel scrolling is now hit-gated by the adapter (legacy hosts called `mouse_wheel`
+//! directly on the hovered widget, so the gate should be a no-op in practice — flagged).
+
use crate::widget::*;
+use crate::scene::layout::{Rect, Size};
+use crate::scene::paint::PaintCtx;
+use crate::widget::model::{Adapted, EventCtx, Input, Layout, Paint};
use std::sync::OnceLock;
static FONT_DB: OnceLock<resvg::usvg::fontdb::Database> = OnceLock::new();
@@ -12,9 +35,18 @@ pub fn get_font_db() -> &'static resvg::usvg::fontdb::Database {
})
}
+/// Side-layout label inset — the legacy `Element::label_x_offset` default for non-exempt
+/// widgets (TextBox was never in the exempt list).
+fn side_offset(label: &Option<String>) -> f32 {
+ if crate::layout::control_label_layout() == "side" && label.is_some() {
+ 90.0
+ } else {
+ 0.0
+ }
+}
+
#[derive(Debug, Clone)]
pub struct TextBox {
- base: Widget,
pub text: String,
pub editing: bool,
pub edit_buffer: String,
@@ -27,7 +59,6 @@ pub struct TextBox {
pub just_focused: bool,
pub drag_start_idx: Option<usize>,
pub parent: Option<*mut (dyn Element + 'static)>,
- pub children: Vec<*mut (dyn Element + 'static)>,
pub max_width: Option<f32>,
pub width: Option<f32>,
pub is_password: bool,
@@ -46,14 +77,20 @@ pub struct TextBox {
pub glyph_positions: Vec<f32>,
pub total_text_width: f32,
pub update_on_type: bool,
+ /// Synced control label ([`Paint::sync_label`]) — drives the side/detached offsets.
+ label: Option<String>,
+ /// Own hover flag, maintained from `MouseEnter`/`MouseLeave` (adapter bookkeeping).
+ hovered: bool,
+ /// The laid-out base rect, cached from [`Layout::rect_assigned`] — the cursor/scroll math
+ /// reads geometry between events, which the narrow traits don't otherwise carry.
+ rect: Rect,
}
impl TextBox {
- pub fn new(text: String) -> Self {
+ pub fn new(text: String) -> Adapted<TextBox> {
let (style_family, style_size) = crate::layout::control_label_font_detached_parsed();
let editor_state = TextEditorState::new(text.clone());
- Self {
- base: Widget::new(),
+ Adapted::new(TextBox {
text,
editing: false,
edit_buffer: String::new(),
@@ -66,7 +103,6 @@ impl TextBox {
just_focused: false,
drag_start_idx: None,
parent: None,
- children: Vec::new(),
max_width: None,
width: None,
is_password: false,
@@ -85,29 +121,36 @@ impl TextBox {
glyph_positions: Vec::new(),
total_text_width: 0.0,
update_on_type: false,
- }
- }
-
- pub fn with_update_on_type(mut self, update: bool) -> Self {
- self.update_on_type = update;
- self
+ label: None,
+ hovered: false,
+ rect: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
+ })
}
- pub fn with_multiline(mut self, multiline: bool) -> Self {
- self.multiline = multiline;
- self
+ /// The detached-label strip height — a replica of `Widget::label_offset` over the synced
+ /// label (zero in side layout or unlabeled).
+ fn label_top(&self) -> f32 {
+ if crate::layout::control_label_layout() == "side" {
+ return 0.0;
+ }
+ if self.label.is_some() {
+ let (_, font_size) = crate::layout::control_label_font_detached_parsed();
+ font_size + crate::layout::control_label_margin()
+ } else {
+ 0.0
+ }
}
fn map_x_to_idx(&self, click_x: f32) -> usize {
- let label_x = self.label_x_offset();
- let relative_x = click_x - (self.base.x + label_x + 8.0) + self.scroll_x;
+ let label_x = side_offset(&self.label);
+ let relative_x = click_x - (self.rect.x + label_x + 8.0) + self.scroll_x;
if self.glyph_positions.is_empty() {
let char_width = self.char_width();
return ((relative_x / char_width).round() as isize)
.max(0)
.min(self.edit_buffer.chars().count() as isize) as usize;
}
-
+
let mut closest_idx = 0;
let mut min_diff = f32::MAX;
for (i, &pos) in self.glyph_positions.iter().enumerate() {
@@ -120,25 +163,6 @@ impl TextBox {
closest_idx
}
- pub fn with_draw_bg_border(mut self, draw: bool) -> Self {
- self.draw_bg_border = draw;
- self
- }
-
- pub fn with_text_color(mut self, color: Option<[u8; 3]>) -> Self {
- self.text_color = color;
- self
- }
- pub fn with_font_size(mut self, size: f32) -> Self {
- self.font_size = size;
- self
- }
-
- pub fn with_font_family(mut self, family: String) -> Self {
- self.font_family = family;
- self
- }
-
pub fn char_width(&self) -> f32 {
crate::widget::display::measure_text_width("M", &self.font_family, self.font_size)
}
@@ -153,7 +177,7 @@ impl TextBox {
let mut lines = Vec::new();
let mut current_line = Vec::new();
let mut index_map = vec![(0, 0); chars.len() + 1];
-
+
if !self.line_wrap_enabled() {
let mut i = 0;
while i < chars.len() {
@@ -172,13 +196,13 @@ impl TextBox {
lines.push(current_line.iter().collect::<String>());
return (lines, index_map);
}
-
+
let max_chars = max_chars_per_line.max(1);
-
+
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
-
+
if ch == '\n' {
index_map[i] = (lines.len(), current_line.len());
lines.push(current_line.iter().collect::<String>());
@@ -186,10 +210,10 @@ impl TextBox {
i += 1;
continue;
}
-
+
current_line.push(ch);
index_map[i] = (lines.len(), current_line.len() - 1);
-
+
if current_line.len() > max_chars {
let mut space_idx = None;
for (s_idx, &c) in current_line.iter().enumerate().rev() {
@@ -198,14 +222,14 @@ impl TextBox {
break;
}
}
-
+
if let Some(s_idx) = space_idx {
let line_to_push: Vec<char> = current_line[0..s_idx + 1].to_vec();
let remaining: Vec<char> = current_line[s_idx + 1..].to_vec();
-
+
let line_idx = lines.len();
lines.push(line_to_push.iter().collect::<String>());
-
+
current_line = remaining;
let start_orig = i - current_line.len() + 1;
for c_idx in 0..current_line.len() {
@@ -214,10 +238,10 @@ impl TextBox {
} else {
let line_to_push: Vec<char> = current_line[0..max_chars].to_vec();
let remaining: Vec<char> = current_line[max_chars..].to_vec();
-
+
let line_idx = lines.len();
lines.push(line_to_push.iter().collect::<String>());
-
+
current_line = remaining;
let start_orig = i - current_line.len() + 1;
for c_idx in 0..current_line.len() {
@@ -227,10 +251,10 @@ impl TextBox {
}
i += 1;
}
-
+
index_map[chars.len()] = (lines.len(), current_line.len());
lines.push(current_line.iter().collect::<String>());
-
+
(lines, index_map)
}
@@ -238,7 +262,7 @@ impl TextBox {
let line = target_line.min(max_line_idx);
let mut best_idx = 0;
let mut best_dist = usize::MAX;
-
+
for (i, &(l, c)) in index_map.iter().enumerate() {
if l == line {
let dist = (c as isize - target_col as isize).abs() as usize;
@@ -251,31 +275,6 @@ impl TextBox {
best_idx
}
- pub fn with_password(mut self, is_password: bool) -> Self {
- self.is_password = is_password;
- self
- }
-
- pub fn with_label(mut self, label: &str) -> Self {
- self.base.label = Some(label.to_string());
- self
- }
-
- pub fn with_config(mut self, file: &str, key: &str) -> Self {
- self.base.config_file = Some(file.to_string());
- self.base.config_key = Some(key.to_string());
- self
- }
-
- pub fn set_label(&mut self, label: &str) {
- self.base.label = Some(label.to_string());
- }
-
- pub fn with_placeholder(mut self, placeholder: &str) -> Self {
- self.placeholder = Some(placeholder.to_string());
- self
- }
-
fn border_width(&self) -> f32 {
if self.multiline {
crate::layout::textbox_multiline_border_width()
@@ -304,20 +303,10 @@ impl TextBox {
self.multiline && crate::layout::textbox_line_wrap()
}
- pub fn with_max_width(mut self, max_w: Option<f32>) -> Self {
- self.max_width = max_w;
- self
- }
-
pub fn set_max_width(&mut self, max_w: Option<f32>) {
self.max_width = max_w;
}
- pub fn with_width(mut self, w: f32) -> Self {
- self.width = Some(w);
- self
- }
-
pub fn set_width(&mut self, w: f32) {
self.width = Some(w);
}
@@ -407,11 +396,33 @@ impl TextBox {
self.sync_editor_state();
}
+ pub fn set_value(&mut self, val: &str) -> bool {
+ let val_str = val.to_string();
+ if self.text != val_str {
+ self.text = val_str.clone();
+ self.edit_buffer = val_str;
+ self.just_changed = true;
+ let len = self.edit_buffer.chars().count();
+ self.cursor_idx = self.cursor_idx.min(len);
+ if let Some(anchor) = self.select_anchor {
+ self.select_anchor = Some(anchor.min(len));
+ }
+ if self.cursor_idx == 0 && self.select_anchor == Some(0) {
+ self.all_selected = false;
+ }
+ self.sync_editor_state();
+ self.clamp_scroll();
+ true
+ } else {
+ false
+ }
+ }
+
pub fn clamp_scroll(&mut self) {
let char_width = self.char_width();
let line_height = self.line_height();
let max_chars = if self.line_wrap_enabled() {
- (((self.base.w - 16.0) / char_width).floor() as usize).max(1)
+ (((self.rect.width - 16.0) / char_width).floor() as usize).max(1)
} else {
999999
};
@@ -424,7 +435,7 @@ impl TextBox {
if self.multiline {
let content_h = lines.len() as f32 * line_height;
- let max_scroll = (content_h - (self.base.h - 16.0)).max(0.0);
+ let max_scroll = (content_h - (self.rect.height - 16.0)).max(0.0);
self.scroll_y = self.scroll_y.clamp(0.0, max_scroll);
} else {
self.scroll_y = 0.0;
@@ -433,7 +444,7 @@ impl TextBox {
if !self.line_wrap_enabled() {
let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
let content_w = max_line_len as f32 * char_width;
- let max_scroll_x = (content_w - (self.base.w - 16.0)).max(0.0);
+ let max_scroll_x = (content_w - (self.rect.width - 16.0)).max(0.0);
self.scroll_x = self.scroll_x.clamp(0.0, max_scroll_x);
} else {
self.scroll_x = 0.0;
@@ -444,7 +455,7 @@ impl TextBox {
let char_width = self.char_width();
let line_height = self.line_height();
let max_chars = if self.line_wrap_enabled() {
- (((self.base.w - 16.0) / char_width).floor() as usize).max(1)
+ (((self.rect.width - 16.0) / char_width).floor() as usize).max(1)
} else {
999999
};
@@ -459,14 +470,14 @@ impl TextBox {
(vec![buffer.clone()], m)
};
if index_map.is_empty() { return; }
-
+
let cursor_idx = self.cursor_idx.min(index_map.len() - 1);
let (line_idx, col_idx) = index_map[cursor_idx];
-
- let top = self.base.label_offset();
- let viewport_w = self.base.w - 16.0;
- let viewport_h = self.base.h - top - 16.0;
-
+
+ let top = self.label_top();
+ let viewport_w = self.rect.width - 16.0;
+ let viewport_h = self.rect.height - top - 16.0;
+
if self.multiline {
let line_y = top + 8.0 + (line_idx as f32 * line_height);
if line_y < self.scroll_y + 10.0 {
@@ -475,7 +486,7 @@ impl TextBox {
self.scroll_y = (line_y + line_height - viewport_h + 20.0).max(0.0);
}
}
-
+
if !self.line_wrap_enabled() {
let cursor_x = col_idx as f32 * char_width;
if cursor_x < self.scroll_x + 10.0 {
@@ -486,377 +497,88 @@ impl TextBox {
}
self.clamp_scroll();
}
-}
-impl Default for TextBox {
- fn default() -> Self {
- Self::new(String::new())
+ /// The legacy `focus()` body minus the global-focus claim (the caller's, via
+ /// `EventCtx::request_focus`).
+ fn begin_editing(&mut self) {
+ if self.disabled { return; }
+ self.editing = true;
+ self.edit_buffer = self.text.clone();
+ let len = self.edit_buffer.chars().count();
+ self.cursor_idx = len;
+ self.select_anchor = Some(0);
+ self.all_selected = len > 0;
+ self.just_focused = true;
+ self.sync_editor_state();
}
-}
-
-impl Element for TextBox {
- crate::impl_widget_base!(TextBox);
-
- fn prepare_text(&mut self, fs: &mut glyphon::FontSystem) {
- let (style_family, style_size) = crate::layout::control_label_font_detached_parsed();
- if self.font_size == self.default_font_size {
- self.font_size = style_size;
- }
- self.default_font_size = style_size;
-
- if self.font_family == self.default_font_family {
- self.font_family = style_family.clone();
- }
- self.default_font_family = style_family;
-
- let text_src = if self.editing { &self.edit_buffer } else { &self.text };
- let display_text = if text_src.is_empty() && self.placeholder.is_some() {
- self.placeholder.as_ref().unwrap().as_str()
- } else {
- text_src.as_str()
- };
-
- let font_fam = if self.is_password {
- "monospace"
- } else {
- self.font_family.as_str()
- };
-
- let render_text = if self.is_password {
- "•".repeat(display_text.chars().count())
- } else {
- display_text.to_string()
- };
-
- let buffer = crate::widget::display::text_label::make_widget_text_buffer(fs, &render_text, self.font_size, font_fam);
-
- let char_count = render_text.chars().count();
- let mut x_offsets = vec![0.0; char_count + 1];
- let mut total_w: f32 = 0.0;
- let scale = crate::scale::scale_factor().max(1.0);
- for run in buffer.layout_runs() {
- for glyph in run.glyphs {
- let byte_offset = glyph.start;
- let c_idx = render_text[..byte_offset.min(render_text.len())].chars().count();
- if c_idx < x_offsets.len() {
- x_offsets[c_idx] = glyph.x / scale;
- }
- total_w = total_w.max((glyph.x + glyph.w) / scale);
+ /// The legacy `unfocus()` body: leave edit mode and commit the buffer.
+ fn commit_editing(&mut self) {
+ if self.editing {
+ self.editing = false;
+ if self.text != self.edit_buffer {
+ self.text = self.edit_buffer.clone();
+ self.just_changed = true;
}
+ self.select_anchor = None;
+ self.all_selected = false;
+ self.sync_editor_state();
}
+ }
- let mut current_x = 0.0;
- for i in 0..x_offsets.len() {
- if x_offsets[i] == 0.0 && i > 0 {
- x_offsets[i] = current_x;
+ /// Map a press/drag position to a buffer index — the shared body of the legacy
+ /// `mouse_input` press arm and `drag_update`.
+ fn position_to_idx(&self, px: f32, py: f32, with_label_x: bool) -> usize {
+ let char_width = self.char_width();
+ let top = self.label_top();
+ let label_x = if with_label_x { side_offset(&self.label) } else { 0.0 };
+ if self.multiline {
+ let line_height = self.line_height();
+ let max_chars = if self.line_wrap_enabled() {
+ ((((self.rect.width - label_x) - 16.0) / char_width).floor() as usize).max(1)
} else {
- current_x = x_offsets[i];
- }
- }
-
- if !x_offsets.is_empty() {
- let last_idx = x_offsets.len() - 1;
- x_offsets[last_idx] = total_w;
+ 999999
+ };
+ let (lines, index_map) = self.wrap_text(max_chars);
+ let click_line = (((py - (self.rect.y + top + 8.0) + self.scroll_y) / line_height).floor() as isize).max(0) as usize;
+ let click_col = (((px - (self.rect.x + label_x + 8.0) + self.scroll_x) / char_width).round() as isize).max(0) as usize;
+ self.map_2d_to_1d(&index_map, click_line, click_col, lines.len() - 1)
+ } else {
+ self.map_x_to_idx(px)
}
-
- self.glyph_positions = x_offsets;
- self.total_text_width = total_w;
-
- let cursor_pos = self.cursor_idx.min(self.glyph_positions.len() - 1);
- self.cursor_x_offset = self.glyph_positions.get(cursor_pos).copied().unwrap_or(0.0);
-
- }
-
- fn get_value_string(&self) -> Option<String> {
- Some(self.text.clone())
}
- fn set_value_string(&mut self, val: &str) -> bool {
- let val_str = val.to_string();
- if self.text != val_str {
- self.text = val_str.clone();
- self.edit_buffer = val_str;
- self.just_changed = true;
+ /// Extend the selection to a drag position — the shared body of the legacy
+ /// `on_cursor_moved` drag arm and `drag_update` (which used no label inset).
+ fn extend_selection_to(&mut self, px: f32, py: f32) -> bool {
+ let drag_idx = self.position_to_idx(px, py, false);
+ if self.cursor_idx != drag_idx {
+ self.cursor_idx = drag_idx;
+ self.just_focused = false;
let len = self.edit_buffer.chars().count();
- self.cursor_idx = self.cursor_idx.min(len);
- if let Some(anchor) = self.select_anchor {
- self.select_anchor = Some(anchor.min(len));
- }
- if self.cursor_idx == 0 && self.select_anchor == Some(0) {
- self.all_selected = false;
- }
- self.sync_editor_state();
- self.clamp_scroll();
+ let start = self.select_anchor.unwrap_or(0).min(self.cursor_idx);
+ let end = self.select_anchor.unwrap_or(0).max(self.cursor_idx);
+ self.all_selected = start == 0 && end == len && len > 0;
true
} else {
false
}
}
- fn take_change(&mut self) -> bool {
- self.take_change()
- }
-
- fn cut_selection(&mut self) -> bool {
- let res = self.cut_selection();
- if res {
- self.just_changed = true;
- }
- res
- }
-
- fn copy_selection(&self) {
- self.copy_selection();
- }
+ /// Port of the legacy `keyboard_input` body.
+ fn handle_key(&mut self, event: &KeyEvent) -> bool {
+ if !self.editing || self.disabled { return false; }
+ if event.state != ElementState::Pressed { return false; }
- fn paste_from_clipboard(&mut self) -> bool {
- let res = self.paste_from_clipboard();
- if res {
- self.just_changed = true;
- }
- res
- }
+ let control = event.ctrl;
- fn select_all(&mut self) {
- self.select_all();
- }
+ let mut state = TextEditorState {
+ buffer: self.edit_buffer.clone(),
+ cursor_idx: self.cursor_idx,
+ select_anchor: self.select_anchor,
+ all_selected: self.all_selected,
+ };
- fn clear_text(&mut self) {
- self.set_value_string("");
- }
-
- fn preferred_height(&self) -> Option<f32> {
- Some(crate::layout::textbox_height())
- }
-
- fn rounded_corners(&self) -> (bool, bool, bool, bool) {
- let r = crate::layout::textbox_corner_radius();
- if r > 0.0 {
- (true, true, true, true)
- } else {
- (false, false, false, false)
- }
- }
-
- fn corner_radius(&self) -> f32 {
- crate::layout::textbox_corner_radius()
- }
-
- fn rect(&self) -> (f32, f32, f32, f32) { (self.base.x, self.base.y, self.base.w, self.base.h) }
- fn set_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
- let final_w = if let Some(explicit_w) = self.width {
- explicit_w
- } else if let Some(max_w) = self.max_width {
- w.min(max_w)
- } else {
- w
- };
- if let Some(b) = self.base_mut() {
- b.x = x;
- b.y = y;
- b.w = final_w;
- b.h = h;
- }
- self.clamp_scroll();
- }
- fn set_row_rect(&mut self, x: f32, w: f32) {
- let final_w = if let Some(explicit_w) = self.width {
- explicit_w
- } else if let Some(max_w) = self.max_width {
- w.min(max_w)
- } else {
- w
- };
- if let Some(b) = self.base_mut() {
- b.row_x = x;
- b.row_w = final_w;
- }
- }
-
- fn color(&self) -> [f32; 4] {
- [0.10, 0.10, 0.16, 1.0]
- }
-
- fn on_cursor_moved(&mut self, px: f32, py: f32, ctx: &mut UiContext) -> bool {
- if self.disabled {
- let was = self.base.hovered;
- self.base.hovered = false;
- return was;
- }
- let mut changed = false;
- if self.dragging && self.editing {
- let char_width = self.char_width();
- let drag_idx = if self.multiline {
- let line_height = self.line_height();
- let max_chars = if self.line_wrap_enabled() {
- (((self.base.w - 16.0) / char_width).floor() as usize).max(1)
- } else {
- 999999
- };
- let (lines, index_map) = self.wrap_text(max_chars);
- let top = self.base.label_offset();
- let click_line = (((py - (self.base.y + top + 8.0) + self.scroll_y) / line_height).floor() as isize).max(0) as usize;
- let click_col = (((px - (self.base.x + 8.0) + self.scroll_x) / char_width).round() as isize).max(0) as usize;
- self.map_2d_to_1d(&index_map, click_line, click_col, lines.len() - 1)
- } else {
- self.map_x_to_idx(px)
- };
- if self.cursor_idx != drag_idx {
- self.cursor_idx = drag_idx;
- self.just_focused = false;
- let len = self.edit_buffer.chars().count();
- let start = self.select_anchor.unwrap_or(0).min(self.cursor_idx);
- let end = self.select_anchor.unwrap_or(0).max(self.cursor_idx);
- self.all_selected = start == 0 && end == len && len > 0;
- changed = true;
- }
- }
- let was = self.base.hovered;
- self.base.hovered = self.hit_test(px, py, ctx);
- if was != self.base.hovered {
- changed = true;
- }
- changed
- }
-
- fn draggable(&self) -> bool { !self.disabled }
- fn is_dragging(&self) -> bool { self.dragging }
- fn widget_font(&self) -> Option<String> { Some(crate::layout::control_label_font_detached()) }
-
- fn drag_begin(&mut self, _px: f32, _py: f32) {
- if self.disabled || !self.editing { return; }
- self.dragging = true;
- }
-
- fn drag_update(&mut self, px: f32, py: f32) -> bool {
- if self.disabled || !self.editing { return false; }
- let char_width = self.char_width();
- let top = self.base.label_offset();
- let drag_idx = if self.multiline {
- let line_height = self.line_height();
- let max_chars = if self.line_wrap_enabled() {
- (((self.base.w - 16.0) / char_width).floor() as usize).max(1)
- } else {
- 999999
- };
- let (lines, index_map) = self.wrap_text(max_chars);
- let click_line = (((py - (self.base.y + top + 8.0) + self.scroll_y) / line_height).floor() as isize).max(0) as usize;
- let click_col = (((px - (self.base.x + 8.0) + self.scroll_x) / char_width).round() as isize).max(0) as usize;
- self.map_2d_to_1d(&index_map, click_line, click_col, lines.len() - 1)
- } else {
- self.map_x_to_idx(px)
- };
- if self.cursor_idx != drag_idx {
- self.cursor_idx = drag_idx;
- self.just_focused = false;
- let len = self.edit_buffer.chars().count();
- let start = self.select_anchor.unwrap_or(0).min(self.cursor_idx);
- let end = self.select_anchor.unwrap_or(0).max(self.cursor_idx);
- self.all_selected = start == 0 && end == len && len > 0;
- return true;
- }
- false
- }
-
- fn drag_end(&mut self) {
- self.dragging = false;
- }
-
- fn value(&self) -> i32 { 0 }
-
- fn mouse_input(&mut self, button: MouseButton, state: ElementState, px: f32, py: f32, ctx: &mut UiContext) -> bool {
- if self.disabled { return false; }
- if button == MouseButton::Right && state == ElementState::Pressed {
- if self.hit_test(px, py, ctx) {
- if !self.editing {
- self.focus();
- }
- ctx.handle_right_click(self.as_ptr_mut(), px, py);
- return true;
- }
- }
- if button != MouseButton::Left { return false; }
- if !self.hit_test(px, py, ctx) { return false; }
- match state {
- ElementState::Pressed => {
- if !self.editing {
- self.focus();
- } else {
- let char_width = self.char_width();
- let top = self.base.label_offset();
- let label_x = self.label_x_offset();
- let idx = if self.multiline {
- let line_height = self.line_height();
- let max_chars = if self.line_wrap_enabled() {
- ((((self.base.w - label_x) - 16.0) / char_width).floor() as usize).max(1)
- } else {
- 999999
- };
- let (lines, index_map) = self.wrap_text(max_chars);
- let click_line = (((py - (self.base.y + top + 8.0) + self.scroll_y) / line_height).floor() as isize).max(0) as usize;
- let click_col = (((px - (self.base.x + label_x + 8.0) + self.scroll_x) / char_width).round() as isize).max(0) as usize;
- self.map_2d_to_1d(&index_map, click_line, click_col, lines.len() - 1)
- } else {
- self.map_x_to_idx(px)
- };
- self.cursor_idx = idx;
- self.select_anchor = Some(idx);
- self.all_selected = false;
- }
- true
- }
- ElementState::Released => {
- if self.dragging {
- self.drag_end();
- }
- if self.select_anchor == Some(self.cursor_idx) {
- self.select_anchor = None;
- }
- true
- }
- }
- }
-
- fn focus(&mut self) {
- if self.disabled { return; }
- self.editing = true;
- self.edit_buffer = self.text.clone();
- let len = self.edit_buffer.chars().count();
- self.cursor_idx = len;
- self.select_anchor = Some(0);
- self.all_selected = len > 0;
- self.just_focused = true;
- self.sync_editor_state();
- focus::set_focused(self);
- }
-
- fn unfocus(&mut self) {
- if self.editing {
- self.editing = false;
- if self.text != self.edit_buffer {
- self.text = self.edit_buffer.clone();
- self.just_changed = true;
- }
- self.select_anchor = None;
- self.all_selected = false;
- self.sync_editor_state();
- }
- }
-
- fn keyboard_input(&mut self, event: &KeyEvent, _ctx: &mut UiContext) -> bool {
- if !self.editing || self.disabled { return false; }
- if event.state != ElementState::Pressed { return false; }
-
- let control = event.ctrl;
-
- let mut state = TextEditorState {
- buffer: self.edit_buffer.clone(),
- cursor_idx: self.cursor_idx,
- select_anchor: self.select_anchor,
- all_selected: self.all_selected,
- };
-
let handled = match &event.logical_key {
Key::Named(NamedKey::Backspace) => {
state.delete_backwards()
@@ -882,7 +604,7 @@ impl Element for TextBox {
}
if self.multiline {
let char_width = self.char_width();
- let max_chars = (((self.base.w - 16.0) / char_width).floor() as usize).max(1);
+ let max_chars = (((self.rect.width - 16.0) / char_width).floor() as usize).max(1);
let (lines, index_map) = self.wrap_text(max_chars);
let (cursor_l, cursor_c) = index_map[state.cursor_idx.min(index_map.len() - 1)];
if cursor_l > 0 {
@@ -907,7 +629,7 @@ impl Element for TextBox {
}
if self.multiline {
let char_width = self.char_width();
- let max_chars = (((self.base.w - 16.0) / char_width).floor() as usize).max(1);
+ let max_chars = (((self.rect.width - 16.0) / char_width).floor() as usize).max(1);
let (lines, index_map) = self.wrap_text(max_chars);
let (cursor_l, cursor_c) = index_map[state.cursor_idx.min(index_map.len() - 1)];
if cursor_l < lines.len() - 1 {
@@ -931,7 +653,7 @@ impl Element for TextBox {
state.insert_text("\n");
true
} else {
- self.unfocus();
+ self.commit_editing();
true
}
}
@@ -985,7 +707,7 @@ impl Element for TextBox {
}
}
};
-
+
if self.editing {
self.edit_buffer = state.buffer;
self.cursor_idx = state.cursor_idx;
@@ -994,509 +716,750 @@ impl Element for TextBox {
self.sync_editor_state();
self.scroll_to_cursor();
}
-
+
handled
}
- fn extra_quads(&self) -> Vec<(f32, f32, f32, f32, [f32; 4])> {
- let mut quads = Vec::new();
- let (r1, r2, r3, r4) = self.rounded_corners();
- let has_rounded = r1 || r2 || r3 || r4;
- if has_rounded {
- return quads;
- }
-
- let top = self.base.label_offset();
- let visual_h = self.base.h - top;
- let border_w = self.border_width();
- if self.disabled {
- if self.draw_bg_border {
- quads.push((self.base.x, self.base.y + top, self.base.w, visual_h, [0.12, 0.12, 0.16, 1.0]));
- quads.push((self.base.x + border_w, self.base.y + top + border_w, self.base.w - 2.0 * border_w, visual_h - 2.0 * border_w, [0.06, 0.06, 0.08, 1.0]));
+ /// Port of the legacy `mouse_wheel` body (scroll the multiline/no-wrap viewports).
+ fn handle_wheel(&mut self, delta: &MouseScrollDelta) -> bool {
+ if self.disabled { return false; }
+ let char_width = self.char_width();
+ let line_height = self.line_height();
+
+ let max_chars = if self.line_wrap_enabled() {
+ (((self.rect.width - 16.0) / char_width).floor() as usize).max(1)
+ } else {
+ 999999
+ };
+
+ let (lines, _) = if self.multiline {
+ self.wrap_text(max_chars)
+ } else {
+ let buffer = if self.editing { &self.edit_buffer } else { &self.text };
+ (vec![buffer.clone()], vec![(0, 0); buffer.chars().count() + 1])
+ };
+
+ let mut changed = false;
+
+ if self.multiline {
+ let content_h = lines.len() as f32 * line_height;
+ let max_scroll = (content_h - (self.rect.height - 16.0)).max(0.0);
+ let scroll_amt = match *delta {
+ MouseScrollDelta::LineDelta(_, dy) => -dy * line_height * 2.0,
+ MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
+ };
+ let old_scroll = self.scroll_y;
+ self.scroll_y = (self.scroll_y + scroll_amt).clamp(0.0, max_scroll);
+ if old_scroll != self.scroll_y {
+ changed = true;
}
- return quads;
}
-
- if self.draw_bg_border {
- let bg_color = if self.editing {
- crate::colors::textbox_background_edit_color()
- } else {
- crate::colors::textbox_background_color()
- };
- let border_color = if self.editing {
- [0.20, 0.50, 0.85, 1.0]
- } else if self.base.hovered {
- [0.25, 0.25, 0.35, 1.0]
- } else {
- [0.18, 0.18, 0.24, 1.0]
+
+ if !self.line_wrap_enabled() {
+ let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
+ let content_w = max_line_len as f32 * char_width;
+ let max_scroll_x = (content_w - (self.rect.width - 16.0)).max(0.0);
+ let natural = crate::layout::touchpad_natural_scroll();
+ let scroll_amt_x = match *delta {
+ MouseScrollDelta::LineDelta(dx, dy) => {
+ if !self.multiline {
+ let scroll_val = if dy != 0.0 { -dy } else { if natural { -dx } else { dx } };
+ scroll_val * char_width * 3.0
+ } else {
+ let scroll_val = if natural { -dx } else { dx };
+ scroll_val * char_width * 3.0
+ }
+ }
+ MouseScrollDelta::PixelDelta(pos) => {
+ if !self.multiline {
+ let scroll_val = if pos.y != 0.0 { -pos.y as f32 } else { if natural { -pos.x as f32 } else { pos.x as f32 } };
+ scroll_val
+ } else {
+ if natural { -pos.x as f32 } else { pos.x as f32 }
+ }
+ }
};
- quads.push((self.base.x, self.base.y + top, self.base.w, visual_h, border_color));
- quads.push((self.base.x + border_w, self.base.y + top + border_w, self.base.w - 2.0 * border_w, visual_h - 2.0 * border_w, bg_color));
+ let old_scroll_x = self.scroll_x;
+ self.scroll_x = (self.scroll_x + scroll_amt_x).clamp(0.0, max_scroll_x);
+ if old_scroll_x != self.scroll_x {
+ changed = true;
+ }
}
- if self.editing || self.select_anchor.is_some() {
- let char_width = self.char_width();
- let line_height = self.line_height();
-
- let highlight_color = [0.20, 0.50, 0.85, 0.3];
- let cursor_color = if self.draw_bg_border {
- [0.80, 0.80, 0.85, 1.0]
- } else {
- [0.10, 0.10, 0.15, 1.0]
- };
+ changed
+ }
- let start = self.select_anchor.unwrap_or(self.cursor_idx).min(self.cursor_idx);
- let end = self.select_anchor.unwrap_or(self.cursor_idx).max(self.cursor_idx);
+ /// Selection highlight + caret quads, shared by both render branches. `x`/`w` are the
+ /// (possibly label-inset) horizontal span the branch draws in — the legacy paths differed
+ /// (non-rounded used the full base span, rounded inset by the side label).
+ fn selection_quads(&self, x: f32, w: f32, out: &mut Vec<(f32, f32, f32, f32, [f32; 4])>) {
+ if !(self.editing || self.select_anchor.is_some()) {
+ return;
+ }
+ let top = self.label_top();
+ let char_width = self.char_width();
+ let line_height = self.line_height();
- if self.multiline {
- let max_chars = if self.line_wrap_enabled() {
- (((self.base.w - 16.0) / char_width).floor() as usize).max(1)
- } else {
- 999999
- };
- let (_lines, index_map) = self.wrap_text(max_chars);
-
- let view_top = self.base.y + top;
- let view_bottom = self.base.y + self.base.h;
-
- if start != end {
- let start_pos = index_map[start.min(index_map.len() - 1)];
- let end_pos = index_map[end.min(index_map.len() - 1)];
-
- for line_idx in start_pos.0..=end_pos.0 {
- let mut line_start_col = None;
- let mut line_end_col = None;
- for idx in start..end {
- if idx < index_map.len() {
- let (l, c) = index_map[idx];
- if l == line_idx {
- if line_start_col.is_none() || c < line_start_col.unwrap() {
- line_start_col = Some(c);
- }
- if line_end_col.is_none() || c > line_end_col.unwrap() {
- line_end_col = Some(c);
- }
+ let highlight_color = [0.20, 0.50, 0.85, 0.3];
+ let cursor_color = if self.draw_bg_border {
+ [0.80, 0.80, 0.85, 1.0]
+ } else {
+ [0.10, 0.10, 0.15, 1.0]
+ };
+
+ let start = self.select_anchor.unwrap_or(self.cursor_idx).min(self.cursor_idx);
+ let end = self.select_anchor.unwrap_or(self.cursor_idx).max(self.cursor_idx);
+
+ if self.multiline {
+ let max_chars = if self.line_wrap_enabled() {
+ (((w - 16.0) / char_width).floor() as usize).max(1)
+ } else {
+ 999999
+ };
+ let (_lines, index_map) = self.wrap_text(max_chars);
+
+ let view_top = self.rect.y + top;
+ let view_bottom = self.rect.y + self.rect.height;
+
+ if start != end {
+ let start_pos = index_map[start.min(index_map.len() - 1)];
+ let end_pos = index_map[end.min(index_map.len() - 1)];
+
+ for line_idx in start_pos.0..=end_pos.0 {
+ let mut line_start_col = None;
+ let mut line_end_col = None;
+ for idx in start..end {
+ if idx < index_map.len() {
+ let (l, c) = index_map[idx];
+ if l == line_idx {
+ if line_start_col.is_none() || c < line_start_col.unwrap() {
+ line_start_col = Some(c);
+ }
+ if line_end_col.is_none() || c > line_end_col.unwrap() {
+ line_end_col = Some(c);
}
}
}
- if let (Some(sc), Some(ec)) = (line_start_col, line_end_col) {
- let highlight_x = self.base.x + 8.0 + (sc as f32 * char_width) - self.scroll_x;
- let highlight_w = (ec - sc + 1) as f32 * char_width;
- let highlight_y = self.base.y + top + 8.0 + (line_idx as f32 * line_height) - self.scroll_y;
- let clipped_y = highlight_y.max(view_top);
- let clipped_bottom = (highlight_y + line_height).min(view_bottom);
- let h_left = highlight_x.max(self.base.x + 8.0);
- let h_right = (highlight_x + highlight_w).min(self.base.x + self.base.w - 8.0);
- if h_left < h_right && clipped_y < clipped_bottom {
- quads.push((
- h_left,
- clipped_y,
- h_right - h_left,
- clipped_bottom - clipped_y,
- highlight_color,
- ));
- }
+ }
+ if let (Some(sc), Some(ec)) = (line_start_col, line_end_col) {
+ let highlight_x = x + 8.0 + (sc as f32 * char_width) - self.scroll_x;
+ let highlight_w = (ec - sc + 1) as f32 * char_width;
+ let highlight_y = self.rect.y + top + 8.0 + (line_idx as f32 * line_height) - self.scroll_y;
+ let clipped_y = highlight_y.max(view_top);
+ let clipped_bottom = (highlight_y + line_height).min(view_bottom);
+ let h_left = highlight_x.max(x + 8.0);
+ let h_right = (highlight_x + highlight_w).min(x + w - 8.0);
+ if h_left < h_right && clipped_y < clipped_bottom {
+ out.push((h_left, clipped_y, h_right - h_left, clipped_bottom - clipped_y, highlight_color));
}
}
}
-
- if self.editing {
- let caret_h = self.font_size * 1.15;
- let (cursor_l, cursor_c) = index_map[self.cursor_idx.min(index_map.len() - 1)];
- let cursor_x = self.base.x + 8.0 + (cursor_c as f32 * char_width) - self.scroll_x;
- let cursor_y = self.base.y + top + 8.0 + (cursor_l as f32 * line_height) + (line_height - caret_h) / 2.0 - self.scroll_y;
- let clipped_y = cursor_y.max(view_top);
- let clipped_bottom = (cursor_y + caret_h).min(view_bottom);
- if cursor_x >= self.base.x + 8.0 && cursor_x <= self.base.x + self.base.w - 8.0 {
- if clipped_y < clipped_bottom {
- quads.push((cursor_x, clipped_y, 1.5, clipped_bottom - clipped_y, cursor_color));
- }
+ }
+
+ if self.editing {
+ let caret_h = self.font_size * 1.15;
+ let (cursor_l, cursor_c) = index_map[self.cursor_idx.min(index_map.len() - 1)];
+ let cursor_x = x + 8.0 + (cursor_c as f32 * char_width) - self.scroll_x;
+ let cursor_y = self.rect.y + top + 8.0 + (cursor_l as f32 * line_height) + (line_height - caret_h) / 2.0 - self.scroll_y;
+ let clipped_y = cursor_y.max(view_top);
+ let clipped_bottom = (cursor_y + caret_h).min(view_bottom);
+ if cursor_x >= x + 8.0 && cursor_x <= x + w - 8.0 {
+ if clipped_y < clipped_bottom {
+ out.push((cursor_x, clipped_y, 1.5, clipped_bottom - clipped_y, cursor_color));
}
}
+ }
+ } else {
+ let caret_h = self.font_size * 1.15;
+ if start != end {
+ let h_left_offset = self.glyph_positions.get(start).copied().unwrap_or_else(|| start as f32 * char_width);
+ let h_right_offset = self.glyph_positions.get(end).copied().unwrap_or_else(|| end as f32 * char_width);
+ let highlight_x = x + 8.0 + h_left_offset - self.scroll_x;
+ let h_left = highlight_x.max(x + 8.0);
+ let h_right = (x + 8.0 + h_right_offset - self.scroll_x).min(x + w - 8.0);
+ if h_left < h_right {
+ out.push((
+ h_left,
+ crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top),
+ h_right - h_left,
+ crate::layout::line_height(self.font_size),
+ highlight_color,
+ ));
+ }
+ }
+
+ if self.editing {
+ let offset = if self.glyph_positions.is_empty() {
+ self.cursor_idx as f32 * char_width
+ } else {
+ self.cursor_x_offset
+ };
+ let cursor_x = x + 8.0 + offset - self.scroll_x;
+ if cursor_x >= x + 8.0 && cursor_x <= x + w - 8.0 {
+ let text_y = crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top);
+ let cursor_y = text_y + (self.font_size - caret_h) / 2.0;
+ out.push((cursor_x, cursor_y, 1.5, caret_h, cursor_color));
+ }
+ }
+ }
+ }
+
+ /// The value/placeholder text lines — the legacy `text_labels` body minus the control
+ /// label (the adapter's base-label machinery draws that).
+ fn value_labels(&self) -> Vec<TextLabel> {
+ let mut labels = Vec::new();
+ let top = self.label_top();
+ let mut val_text = if self.editing {
+ self.edit_buffer.clone()
+ } else {
+ self.text.clone()
+ };
+ if self.is_password {
+ val_text = "•".repeat(val_text.chars().count());
+ }
+
+ let is_placeholder = val_text.is_empty() && self.placeholder.is_some();
+ let display_text = if is_placeholder {
+ self.placeholder.as_ref().unwrap().clone()
+ } else {
+ val_text
+ };
+
+ let label_color = if is_placeholder {
+ crate::colors::textbox_placeholder_text_color()
+ } else if let Some(custom_color) = self.text_color {
+ custom_color
+ } else if self.disabled {
+ [0x53, 0x53, 0x5a]
+ } else if self.all_selected {
+ [0xff, 0xff, 0xff]
+ } else if self.editing {
+ [0xee, 0xee, 0xf5]
+ } else {
+ [0xcc, 0xcc, 0xd4]
+ };
+
+ let label_x = side_offset(&self.label);
+ let x = self.rect.x + label_x;
+ let w = self.rect.width - label_x;
+
+ if self.multiline {
+ let char_width = self.char_width();
+ let line_height = self.line_height();
+ let max_chars = if self.line_wrap_enabled() {
+ (((w - 16.0) / char_width).floor() as usize).max(1)
} else {
- let caret_h = self.font_size * 1.15;
- if start != end {
- let h_left_offset = self.glyph_positions.get(start).copied().unwrap_or_else(|| start as f32 * char_width);
- let h_right_offset = self.glyph_positions.get(end).copied().unwrap_or_else(|| end as f32 * char_width);
- let highlight_x = self.base.x + 8.0 + h_left_offset - self.scroll_x;
- let h_left = highlight_x.max(self.base.x + 8.0);
- let h_right = (self.base.x + 8.0 + h_right_offset - self.scroll_x).min(self.base.x + self.base.w - 8.0);
- if h_left < h_right {
- quads.push((
- h_left,
- crate::layout::align_text_y(self.base.y, self.base.h, self.font_size, top),
- h_right - h_left,
- crate::layout::line_height(self.font_size),
- highlight_color,
- ));
+ 999999
+ };
+ let (lines, _) = self.wrap_text(max_chars);
+ let lines_to_draw = if is_placeholder {
+ let placeholder_src = self.placeholder.as_ref().unwrap();
+ let chars: Vec<char> = placeholder_src.chars().collect();
+ let mut p_lines = Vec::new();
+ let mut current_line = Vec::new();
+ for ch in chars {
+ if ch == '\n' {
+ p_lines.push(current_line.iter().collect::<String>());
+ current_line.clear();
+ } else {
+ current_line.push(ch);
+ if self.line_wrap_enabled() && current_line.len() > max_chars {
+ p_lines.push(current_line.iter().collect::<String>());
+ current_line.clear();
+ }
}
}
+ p_lines.push(current_line.iter().collect::<String>());
+ p_lines
+ } else {
+ lines
+ };
+ for (line_idx, line_text) in lines_to_draw.iter().enumerate() {
+ labels.push(TextLabel {
+ text: line_text.clone(),
+ x: x + 8.0 - self.scroll_x,
+ y: self.rect.y + top + 8.0 + (line_idx as f32 * line_height) + (line_height - self.font_size) / 2.0 - self.scroll_y,
+ font_size: self.font_size,
+ color: label_color,
+ });
+ }
+ } else {
+ labels.push(TextLabel {
+ text: display_text,
+ x: x + 8.0 - self.scroll_x,
+ y: crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top),
+ font_size: self.font_size,
+ color: label_color,
+ });
+ }
+ labels
+ }
+}
+
+impl Adapted<TextBox> {
+ pub fn with_update_on_type(mut self, update: bool) -> Self {
+ self.update_on_type = update;
+ self
+ }
+
+ pub fn with_multiline(mut self, multiline: bool) -> Self {
+ self.multiline = multiline;
+ self
+ }
+
+ pub fn with_draw_bg_border(mut self, draw: bool) -> Self {
+ self.draw_bg_border = draw;
+ self
+ }
+
+ pub fn with_text_color(mut self, color: Option<[u8; 3]>) -> Self {
+ self.text_color = color;
+ self
+ }
+
+ pub fn with_font_size(mut self, size: f32) -> Self {
+ self.font_size = size;
+ self
+ }
+
+ pub fn with_font_family(mut self, family: String) -> Self {
+ self.font_family = family;
+ self
+ }
+
+ pub fn with_password(mut self, is_password: bool) -> Self {
+ self.is_password = is_password;
+ self
+ }
+
+ pub fn with_placeholder(mut self, placeholder: &str) -> Self {
+ self.placeholder = Some(placeholder.to_string());
+ self
+ }
+
+ pub fn with_max_width(mut self, max_w: Option<f32>) -> Self {
+ self.max_width = max_w;
+ self
+ }
- if self.editing {
- let offset = if self.glyph_positions.is_empty() {
- self.cursor_idx as f32 * char_width
+ pub fn with_width(mut self, w: f32) -> Self {
+ self.width = Some(w);
+ self
+ }
+}
+
+impl Layout for TextBox {
+ fn inflates_label_rect(&self) -> bool {
+ false
+ }
+
+ fn detached_label_inset(&self) -> f32 {
+ 4.0
+ }
+
+ fn intrinsic_size(&self) -> Option<Size> {
+ Some(Size::new(0.0, crate::layout::textbox_height()))
+ }
+
+ fn hit_row_rect(&self) -> bool {
+ true
+ }
+
+ /// The legacy `set_rect` width clamp: an explicit `width` wins, else cap at `max_width`.
+ fn adjust_rect(&self, requested: Rect) -> Rect {
+ let final_w = if let Some(explicit_w) = self.width {
+ explicit_w
+ } else if let Some(max_w) = self.max_width {
+ requested.width.min(max_w)
+ } else {
+ requested.width
+ };
+ Rect { width: final_w, ..requested }
+ }
+
+ /// The legacy `set_row_rect` applied the same clamp to the row span.
+ fn adjust_row_rect(&self, x: f32, w: f32) -> (f32, f32) {
+ let final_w = if let Some(explicit_w) = self.width {
+ explicit_w
+ } else if let Some(max_w) = self.max_width {
+ w.min(max_w)
+ } else {
+ w
+ };
+ (x, final_w)
+ }
+
+ fn rect_assigned(&mut self, rect: Rect) {
+ self.rect = rect;
+ self.clamp_scroll();
+ }
+}
+
+impl Paint for TextBox {
+ fn color(&self) -> [f32; 4] {
+ [0.10, 0.10, 0.16, 1.0]
+ }
+
+ fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
+ let r = crate::layout::textbox_corner_radius();
+ if r > 0.0 {
+ Some((r, (true, true, true, true)))
+ } else {
+ Some((r, (false, false, false, false)))
+ }
+ }
+
+ fn widget_font(&self) -> Option<String> {
+ Some(crate::layout::control_label_font_detached())
+ }
+
+ fn sync_label(&mut self, label: &str) {
+ self.label = Some(label.to_string());
+ }
+
+ /// Legacy TextBox kept the shared focus-highlight overlay (the focused editor's
+ /// primary-tint wash — data-editor's teal editing surface).
+ fn legacy_focus_highlight(&self) -> bool {
+ true
+ }
+
+ fn text_bounds(&self, rect: Rect) -> Option<[f32; 4]> {
+ // Legacy bounded-text getters clipped to the full base rect, inset on the left by the
+ // side label.
+ let top = self.label_top();
+ let base_y = rect.y - top;
+ let base_h = rect.height + top;
+ let label_x = side_offset(&self.label);
+ Some([rect.x + label_x, base_y, rect.x + rect.width, base_y + base_h])
+ }
+
+ /// The legacy `prepare_text`: sync font family/size with the live config defaults, then
+ /// shape the display text and record per-glyph advances (`map_x_to_idx` reads them).
+ fn prepare_text(&mut self, fs: &mut glyphon::FontSystem, _rect: Rect) {
+ let (style_family, style_size) = crate::layout::control_label_font_detached_parsed();
+ if self.font_size == self.default_font_size {
+ self.font_size = style_size;
+ }
+ self.default_font_size = style_size;
+
+ if self.font_family == self.default_font_family {
+ self.font_family = style_family.clone();
+ }
+ self.default_font_family = style_family;
+
+ let text_src = if self.editing { &self.edit_buffer } else { &self.text };
+ let display_text = if text_src.is_empty() && self.placeholder.is_some() {
+ self.placeholder.as_ref().unwrap().as_str()
+ } else {
+ text_src.as_str()
+ };
+
+ let font_fam = if self.is_password {
+ "monospace"
+ } else {
+ self.font_family.as_str()
+ };
+
+ let render_text = if self.is_password {
+ "•".repeat(display_text.chars().count())
+ } else {
+ display_text.to_string()
+ };
+
+ let buffer = crate::widget::display::text_label::make_widget_text_buffer(fs, &render_text, self.font_size, font_fam);
+
+ let char_count = render_text.chars().count();
+ let mut x_offsets = vec![0.0; char_count + 1];
+ let mut total_w: f32 = 0.0;
+ let scale = crate::scale::scale_factor().max(1.0);
+
+ for run in buffer.layout_runs() {
+ for glyph in run.glyphs {
+ let byte_offset = glyph.start;
+ let c_idx = render_text[..byte_offset.min(render_text.len())].chars().count();
+ if c_idx < x_offsets.len() {
+ x_offsets[c_idx] = glyph.x / scale;
+ }
+ total_w = total_w.max((glyph.x + glyph.w) / scale);
+ }
+ }
+
+ let mut current_x = 0.0;
+ for i in 0..x_offsets.len() {
+ if x_offsets[i] == 0.0 && i > 0 {
+ x_offsets[i] = current_x;
+ } else {
+ current_x = x_offsets[i];
+ }
+ }
+
+ if !x_offsets.is_empty() {
+ let last_idx = x_offsets.len() - 1;
+ x_offsets[last_idx] = total_w;
+ }
+
+ self.glyph_positions = x_offsets;
+ self.total_text_width = total_w;
+
+ let cursor_pos = self.cursor_idx.min(self.glyph_positions.len() - 1);
+ self.cursor_x_offset = self.glyph_positions.get(cursor_pos).copied().unwrap_or(0.0);
+ }
+
+ fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+ let top = self.label_top();
+ let base_y = rect.y - top;
+ let base_h = rect.height + top;
+ let visual_h = rect.height;
+ let radius = crate::layout::textbox_corner_radius();
+ let border_w = self.border_width();
+
+ // Keep the model's cached rect and the paint rect consistent: paint receives the
+ // content rect derived from the same base the cache holds, so the bodies below read
+ // `self.rect` (the legacy `self.base`) exactly as legacy did. `rect` is used only to
+ // localize this frame's geometry.
+ let _ = (base_y, base_h);
+
+ if radius <= 0.0 {
+ // Legacy `extra_quads`: full base span (no side-label inset), disabled
+ // special-case with early return.
+ let mut quads: Vec<(f32, f32, f32, f32, [f32; 4])> = Vec::new();
+ if self.disabled {
+ if self.draw_bg_border {
+ quads.push((self.rect.x, self.rect.y + top, self.rect.width, visual_h, [0.12, 0.12, 0.16, 1.0]));
+ quads.push((self.rect.x + border_w, self.rect.y + top + border_w, self.rect.width - 2.0 * border_w, visual_h - 2.0 * border_w, [0.06, 0.06, 0.08, 1.0]));
+ }
+ } else {
+ if self.draw_bg_border {
+ let bg_color = if self.editing {
+ crate::colors::textbox_background_edit_color()
} else {
- self.cursor_x_offset
+ crate::colors::textbox_background_color()
};
- let cursor_x = self.base.x + 8.0 + offset - self.scroll_x;
- if cursor_x >= self.base.x + 8.0 && cursor_x <= self.base.x + self.base.w - 8.0 {
- let text_y = crate::layout::align_text_y(self.base.y, self.base.h, self.font_size, top);
- let cursor_y = text_y + (self.font_size - caret_h) / 2.0;
- quads.push((cursor_x, cursor_y, 1.5, caret_h, cursor_color));
- }
+ let border_color = if self.editing {
+ [0.20, 0.50, 0.85, 1.0]
+ } else if self.hovered {
+ [0.25, 0.25, 0.35, 1.0]
+ } else {
+ [0.18, 0.18, 0.24, 1.0]
+ };
+ quads.push((self.rect.x, self.rect.y + top, self.rect.width, visual_h, border_color));
+ quads.push((self.rect.x + border_w, self.rect.y + top + border_w, self.rect.width - 2.0 * border_w, visual_h - 2.0 * border_w, bg_color));
}
+ self.selection_quads(self.rect.x, self.rect.width, &mut quads);
}
- }
+ for (qx, qy, qw, qh, qc) in quads {
+ ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
+ }
+ } else {
+ // Legacy `all_rounded_quads`: side-label inset, no disabled special-case.
+ let label_x = side_offset(&self.label);
+ let x = self.rect.x + label_x;
+ let w = self.rect.width - label_x;
- quads
- }
+ let bg_color = if self.editing {
+ crate::colors::textbox_background_edit_color()
+ } else {
+ crate::colors::textbox_background_color()
+ };
+ let border_color = if self.editing {
+ [0.20, 0.50, 0.85, 1.0]
+ } else if self.hovered {
+ [0.25, 0.25, 0.35, 1.0]
+ } else {
+ [0.18, 0.18, 0.24, 1.0]
+ };
- fn all_rounded_quads(&self, ctx: &UiContext) -> Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))> {
- let mut quads = Vec::new();
- let (r1, r2, r3, r4) = self.rounded_corners();
- let has_rounded = r1 || r2 || r3 || r4;
- if !has_rounded {
- for &child_ptr in &self.children(ctx) {
- let widget = unsafe { &*child_ptr };
- quads.extend(widget.all_rounded_quads(ctx));
+ if self.draw_bg_border {
+ let corners = (true, true, true, true);
+ ctx.rounded_rect(Rect { x, y: self.rect.y + top, width: w, height: visual_h }, radius, corners, border_color);
+ ctx.rounded_rect(
+ Rect { x: x + border_w, y: self.rect.y + top + border_w, width: w - 2.0 * border_w, height: visual_h - 2.0 * border_w },
+ (radius - border_w).max(0.0),
+ corners,
+ bg_color,
+ );
}
- return quads;
- }
- let top = self.base.label_offset();
- let visual_h = self.base.h - top;
- let radius = self.corner_radius();
-
- let bg_color = if self.editing {
- crate::colors::textbox_background_edit_color()
- } else {
- crate::colors::textbox_background_color()
- };
- let border_color = if self.editing {
- [0.20, 0.50, 0.85, 1.0]
- } else if self.base.hovered {
- [0.25, 0.25, 0.35, 1.0]
- } else {
- [0.18, 0.18, 0.24, 1.0]
- };
-
- let label_x = self.label_x_offset();
- let x = self.base.x + label_x;
- let w = self.base.w - label_x;
-
- if self.draw_bg_border {
- let border_w = self.border_width();
- quads.push((x, self.base.y + top, w, visual_h, radius, border_color, (r1, r2, r3, r4)));
- quads.push((x + border_w, self.base.y + top + border_w, w - 2.0 * border_w, visual_h - 2.0 * border_w, (radius - border_w).max(0.0), bg_color, (r1, r2, r3, r4)));
+ let mut quads: Vec<(f32, f32, f32, f32, [f32; 4])> = Vec::new();
+ self.selection_quads(x, w, &mut quads);
+ for (qx, qy, qw, qh, qc) in quads {
+ ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
+ }
}
- if self.editing || self.select_anchor.is_some() {
- let char_width = self.char_width();
- let line_height = self.line_height();
-
- let highlight_color = [0.20, 0.50, 0.85, 0.3];
- let cursor_color = if self.draw_bg_border {
- [0.80, 0.80, 0.85, 1.0]
- } else {
- [0.10, 0.10, 0.15, 1.0]
- };
+ for tl in self.value_labels() {
+ ctx.text(tl.text, tl.x, tl.y, tl.font_size, tl.color);
+ }
+ }
+}
- let start = self.select_anchor.unwrap_or(self.cursor_idx).min(self.cursor_idx);
- let end = self.select_anchor.unwrap_or(self.cursor_idx).max(self.cursor_idx);
+impl Input for TextBox {
+ fn tracks_base_focus(&self) -> bool {
+ false
+ }
- if self.multiline {
- let max_chars = if self.line_wrap_enabled() {
- (((w - 16.0) / char_width).floor() as usize).max(1)
+ fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
+ match event {
+ Event::MouseButton { button: MouseButton::Right, state: ElementState::Pressed, x: px, y: py, .. } => {
+ if self.disabled { return false; }
+ // Adapter hit-gates presses; legacy focused an un-editing box before opening
+ // the menu (work-before-menu, so `opens_context_menu` can't express it).
+ if !self.editing {
+ self.begin_editing();
+ ectx.request_focus();
+ }
+ ectx.open_context_menu(*px, *py);
+ true
+ }
+ Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x: px, y: py, .. } => {
+ if self.disabled { return false; }
+ if !self.editing {
+ self.begin_editing();
+ ectx.request_focus();
} else {
- 999999
- };
- let (_lines, index_map) = self.wrap_text(max_chars);
-
- let view_top = self.base.y + top;
- let view_bottom = self.base.y + self.base.h;
-
- if start != end {
- let start_pos = index_map[start.min(index_map.len() - 1)];
- let end_pos = index_map[end.min(index_map.len() - 1)];
-
- for line_idx in start_pos.0..=end_pos.0 {
- let mut line_start_col = None;
- let mut line_end_col = None;
- for idx in start..end {
- if idx < index_map.len() {
- let (l, c) = index_map[idx];
- if l == line_idx {
- if line_start_col.is_none() || c < line_start_col.unwrap() {
- line_start_col = Some(c);
- }
- if line_end_col.is_none() || c > line_end_col.unwrap() {
- line_end_col = Some(c);
- }
- }
- }
- }
- if let (Some(sc), Some(ec)) = (line_start_col, line_end_col) {
- let highlight_x = x + 8.0 + (sc as f32 * char_width) - self.scroll_x;
- let highlight_w = (ec - sc + 1) as f32 * char_width;
- let highlight_y = self.base.y + top + 8.0 + (line_idx as f32 * line_height) - self.scroll_y;
- let clipped_y = highlight_y.max(view_top);
- let clipped_bottom = (highlight_y + line_height).min(view_bottom);
- let h_left = highlight_x.max(x + 8.0);
- let h_right = (highlight_x + highlight_w).min(x + w - 8.0);
- if h_left < h_right && clipped_y < clipped_bottom {
- quads.push((h_left, clipped_y, h_right - h_left, clipped_bottom - clipped_y, 0.0, highlight_color, (false, false, false, false)));
- }
- }
- }
+ let idx = self.position_to_idx(*px, *py, true);
+ self.cursor_idx = idx;
+ self.select_anchor = Some(idx);
+ self.all_selected = false;
}
-
- if self.editing {
- let caret_h = self.font_size * 1.15;
- let (cursor_l, cursor_c) = index_map[self.cursor_idx.min(index_map.len() - 1)];
- let cursor_x = x + 8.0 + (cursor_c as f32 * char_width) - self.scroll_x;
- let cursor_y = self.base.y + top + 8.0 + (cursor_l as f32 * line_height) + (line_height - caret_h) / 2.0 - self.scroll_y;
- let clipped_y = cursor_y.max(view_top);
- let clipped_bottom = (cursor_y + caret_h).min(view_bottom);
- if cursor_x >= x + 8.0 && cursor_x <= x + w - 8.0 {
- if clipped_y < clipped_bottom {
- quads.push((cursor_x, clipped_y, 1.5, clipped_bottom - clipped_y, 0.0, cursor_color, (false, false, false, false)));
- }
- }
+ true
+ }
+ Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, x: px, y: py, .. } => {
+ if self.disabled { return false; }
+ // Legacy gated releases on the hit test; the adapter delivers them ungated, so
+ // re-check containment (plain rect + side inset — row spans approximated).
+ let label_x = side_offset(&self.label);
+ let top = self.label_top();
+ let (bx, by, bw, bh) = (self.rect.x + label_x, self.rect.y, self.rect.width - label_x, self.rect.height);
+ let _ = top;
+ if !(*px >= bx && *px <= bx + bw && *py >= by && *py <= by + bh) {
+ return false;
}
- } else {
- let caret_h = self.font_size * 1.15;
- if start != end {
- let h_left_offset = self.glyph_positions.get(start).copied().unwrap_or_else(|| start as f32 * char_width);
- let h_right_offset = self.glyph_positions.get(end).copied().unwrap_or_else(|| end as f32 * char_width);
- let highlight_x = x + 8.0 + h_left_offset - self.scroll_x;
- let h_left = highlight_x.max(x + 8.0);
- let h_right = (x + 8.0 + h_right_offset - self.scroll_x).min(x + w - 8.0);
- if h_left < h_right {
- quads.push((
- h_left,
- crate::layout::align_text_y(self.base.y, self.base.h, self.font_size, top),
- h_right - h_left,
- crate::layout::line_height(self.font_size),
- 0.0,
- highlight_color,
- (false, false, false, false),
- ));
- }
+ if self.dragging {
+ self.dragging = false;
}
-
- if self.editing {
- let offset = if self.glyph_positions.is_empty() {
- self.cursor_idx as f32 * char_width
- } else {
- self.cursor_x_offset
- };
- let cursor_x = x + 8.0 + offset - self.scroll_x;
- if cursor_x >= x + 8.0 && cursor_x <= x + w - 8.0 {
- let text_y = crate::layout::align_text_y(self.base.y, self.base.h, self.font_size, top);
- let cursor_y = text_y + (self.font_size - caret_h) / 2.0;
- quads.push((cursor_x, cursor_y, 1.5, caret_h, 0.0, cursor_color, (false, false, false, false)));
- }
+ if self.select_anchor == Some(self.cursor_idx) {
+ self.select_anchor = None;
}
+ true
}
+ Event::PointerMove { x: px, y: py, .. } => {
+ // The drag-selection half of the legacy `on_cursor_moved`; hover bookkeeping
+ // is the adapter's (Enter/Leave below).
+ if self.disabled {
+ return false;
+ }
+ if self.dragging && self.editing {
+ return self.extend_selection_to(*px, *py);
+ }
+ false
+ }
+ Event::MouseEnter => {
+ self.hovered = !self.disabled;
+ true
+ }
+ Event::MouseLeave => {
+ self.hovered = false;
+ true
+ }
+ Event::MouseWheel { delta, .. } => self.handle_wheel(delta),
+ Event::KeyInput(key_event) => self.handle_key(key_event),
+ Event::FocusIn => {
+ // The legacy `focus()`: enter editing and claim the global slot (unless
+ // disabled — legacy early-returned before `set_focused`).
+ if !self.disabled {
+ self.begin_editing();
+ ectx.request_focus();
+ }
+ false
+ }
+ Event::FocusOut => {
+ self.commit_editing();
+ false
+ }
+ _ => false,
}
-
- for &child_ptr in &self.children(ctx) {
- let widget = unsafe { &*child_ptr };
- quads.extend(widget.all_rounded_quads(ctx));
- }
- quads
}
- fn text_labels(&self) -> Vec<TextLabel> {
- let mut labels = Vec::new();
- let top = self.base.label_offset();
- let _visual_h = self.base.h - top;
- if let Some(lbl) = self.control_label() {
- labels.push(lbl);
- }
- let mut val_text = if self.editing {
- self.edit_buffer.clone()
- } else {
- self.text.clone()
- };
- if self.is_password {
- val_text = "•".repeat(val_text.chars().count());
- }
+ fn take_change(&mut self) -> bool {
+ self.take_change()
+ }
- let is_placeholder = val_text.is_empty() && self.placeholder.is_some();
- let display_text = if is_placeholder {
- self.placeholder.as_ref().unwrap().clone()
- } else {
- val_text
- };
+ fn value_string(&self) -> Option<String> {
+ Some(self.text.clone())
+ }
- let label_color = if is_placeholder {
- crate::colors::textbox_placeholder_text_color()
- } else if let Some(custom_color) = self.text_color {
- custom_color
- } else if self.disabled {
- [0x53, 0x53, 0x5a]
- } else if self.all_selected {
- [0xff, 0xff, 0xff]
- } else if self.editing {
- [0xee, 0xee, 0xf5]
- } else {
- [0xcc, 0xcc, 0xd4]
- };
+ fn set_value_string(&mut self, val: &str) -> bool {
+ self.set_value(val)
+ }
- let label_x = self.label_x_offset();
- let x = self.base.x + label_x;
- let w = self.base.w - label_x;
+ fn cut_selection(&mut self) -> bool {
+ let res = self.cut_selection();
+ if res {
+ self.just_changed = true;
+ }
+ res
+ }
- if self.multiline {
- let char_width = self.char_width();
- let line_height = self.line_height();
- let max_chars = if self.line_wrap_enabled() {
- (((w - 16.0) / char_width).floor() as usize).max(1)
- } else {
- 999999
- };
- let (lines, _) = self.wrap_text(max_chars);
- let lines_to_draw = if is_placeholder {
- let placeholder_src = self.placeholder.as_ref().unwrap();
- let chars: Vec<char> = placeholder_src.chars().collect();
- let mut p_lines = Vec::new();
- let mut current_line = Vec::new();
- for ch in chars {
- if ch == '\n' {
- p_lines.push(current_line.iter().collect::<String>());
- current_line.clear();
- } else {
- current_line.push(ch);
- if self.line_wrap_enabled() && current_line.len() > max_chars {
- p_lines.push(current_line.iter().collect::<String>());
- current_line.clear();
- }
- }
- }
- p_lines.push(current_line.iter().collect::<String>());
- p_lines
- } else {
- lines
- };
- for (line_idx, line_text) in lines_to_draw.iter().enumerate() {
- labels.push(TextLabel {
- text: line_text.clone(),
- x: x + 8.0 - self.scroll_x,
- y: self.base.y + top + 8.0 + (line_idx as f32 * line_height) + (line_height - self.font_size) / 2.0 - self.scroll_y,
- font_size: self.font_size,
- color: label_color,
- });
- }
- } else {
- labels.push(TextLabel {
- text: display_text,
- x: x + 8.0 - self.scroll_x,
- y: crate::layout::align_text_y(self.base.y, self.base.h, self.font_size, top),
- font_size: self.font_size,
- color: label_color,
- });
+ fn copy_selection(&self) {
+ self.copy_selection();
+ }
+
+ fn paste_from_clipboard(&mut self) -> bool {
+ let res = self.paste_from_clipboard();
+ if res {
+ self.just_changed = true;
}
- labels
+ res
}
- fn text_labels_with_bounds(&self, _ctx: &UiContext) -> Vec<(TextLabel, Option<[f32; 4]>)> {
- let label_x = self.label_x_offset();
- let bounds = Some([self.base.x + label_x, self.base.y, self.base.x + self.base.w, self.base.y + self.base.h]);
- self.text_labels().into_iter().map(|l| (l, bounds)).collect()
+ fn select_all(&mut self) {
+ self.select_all();
}
- fn text_labels_with_font_and_bounds(&self, _ctx: &UiContext) -> Vec<(TextLabel, Option<String>, Option<[f32; 4]>)> {
- let font = self.widget_font();
- let label_x = self.label_x_offset();
- let bounds = Some([self.base.x + label_x, self.base.y, self.base.x + self.base.w, self.base.y + self.base.h]);
- self.text_labels().into_iter().map(|l| (l, font.clone(), bounds)).collect()
+ fn clear_text(&mut self) {
+ self.set_value("");
}
- fn mouse_wheel(&mut self, delta: &MouseScrollDelta, _px: f32, _py: f32, ctx: &mut UiContext) -> bool {
- if self.disabled { return false; }
- let char_width = self.char_width();
- let line_height = self.line_height();
-
- let max_chars = if self.line_wrap_enabled() {
- (((self.base.w - 16.0) / char_width).floor() as usize).max(1)
- } else {
- 999999
- };
-
- let (lines, _) = if self.multiline {
- self.wrap_text(max_chars)
- } else {
- let buffer = if self.editing { &self.edit_buffer } else { &self.text };
- (vec![buffer.clone()], vec![(0, 0); buffer.chars().count() + 1])
- };
+ fn draggable(&self, _rect: Rect) -> bool {
+ !self.disabled
+ }
- let mut changed = false;
+ fn is_dragging(&self) -> bool {
+ self.dragging
+ }
- if self.multiline {
- let content_h = lines.len() as f32 * line_height;
- let max_scroll = (content_h - (self.base.h - 16.0)).max(0.0);
- let scroll_amt = match *delta {
- MouseScrollDelta::LineDelta(_, dy) => -dy * line_height * 2.0,
- MouseScrollDelta::PixelDelta(pos) => -pos.y as f32,
- };
- let old_scroll = self.scroll_y;
- self.scroll_y = (self.scroll_y + scroll_amt).clamp(0.0, max_scroll);
- if old_scroll != self.scroll_y {
- changed = true;
- }
- }
+ fn drag_begin(&mut self, _px: f32, _py: f32, _rect: Rect) {
+ if self.disabled || !self.editing { return; }
+ self.dragging = true;
+ }
- if !self.line_wrap_enabled() {
- let max_line_len = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
- let content_w = max_line_len as f32 * char_width;
- let max_scroll_x = (content_w - (self.base.w - 16.0)).max(0.0);
- let natural = crate::layout::touchpad_natural_scroll();
- let scroll_amt_x = match *delta {
- MouseScrollDelta::LineDelta(dx, dy) => {
- if !self.multiline {
- let scroll_val = if dy != 0.0 { -dy } else { if natural { -dx } else { dx } };
- scroll_val * char_width * 3.0
- } else {
- let scroll_val = if natural { -dx } else { dx };
- scroll_val * char_width * 3.0
- }
- }
- MouseScrollDelta::PixelDelta(pos) => {
- if !self.multiline {
- let scroll_val = if pos.y != 0.0 { -pos.y as f32 } else { if natural { -pos.x as f32 } else { pos.x as f32 } };
- scroll_val
- } else {
- if natural { -pos.x as f32 } else { pos.x as f32 }
- }
- }
- };
- let old_scroll_x = self.scroll_x;
- self.scroll_x = (self.scroll_x + scroll_amt_x).clamp(0.0, max_scroll_x);
- if old_scroll_x != self.scroll_x {
- changed = true;
- }
- }
+ fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
+ if self.disabled || !self.editing { return false; }
+ self.extend_selection_to(px, py)
+ }
- if changed {
- self.mark_dirty(ctx);
- true
- } else {
- false
- }
+ fn drag_end(&mut self) {
+ self.dragging = false;
}
}
-impl Drop for TextBox {
- fn drop(&mut self) {
- clear_widget_references(self);
+/// Legacy `Default` (an empty box) — settings' accounts page derives `Default` over fields of
+/// this type.
+impl Default for Adapted<TextBox> {
+ fn default() -> Self {
+ TextBox::new(String::new())
}
}
unsafe impl Send for TextBox {}
unsafe impl Sync for TextBox {}
-impl Control for TextBox {}
+impl Control for Adapted<TextBox> {
+ fn set_label(&mut self, label: &str) {
+ Adapted::set_label(self, label);
+ }
+}
#[cfg(test)]
mod tests {
@@ -1664,12 +1627,14 @@ mod tests {
tb.set_rect(10.0, 10.0, 200.0, 100.0);
tb.select_anchor = Some(7); // starts at "Line 2"
tb.cursor_idx = 13; // ends at end of "Line 2"
-
+
let has_rounded = tb.rounded_corners() != (false, false, false, false);
let has_highlight = if has_rounded {
let rounded = tb.all_rounded_quads(&dummy);
println!("Rounded quads: {:?}", rounded);
+ let quads = tb.all_quads(&dummy);
rounded.iter().any(|q| q.5 == [0.20, 0.50, 0.85, 0.3])
+ || quads.iter().any(|q| q.4 == [0.20, 0.50, 0.85, 0.3])
} else {
let extra = tb.extra_quads();
println!("Extra quads: {:?}", extra);
@@ -1682,18 +1647,18 @@ mod tests {
fn test_textbox_line_wrap_disabled_horizontal_scrolling() {
let _dummy = crate::context::UiContext::new();
crate::layout::set_textbox_line_wrap(false);
-
+
let mut tb = TextBox::new("Very long text that should not wrap and instead scroll horizontally".to_string());
tb.set_rect(10.0, 10.0, 100.0, 30.0);
-
+
assert_eq!(tb.scroll_x, 0.0);
-
+
tb.focus();
tb.cursor_idx = tb.edit_buffer.chars().count();
tb.scroll_to_cursor();
-
+
assert!(tb.scroll_x > 0.0, "scroll_x should be scrolled horizontally to keep the cursor visible");
-
+
crate::layout::set_textbox_line_wrap(true);
}
@@ -1713,7 +1678,7 @@ mod tests {
assert!(opts.contains(&"Cear".to_string()));
// Simulate choosing the "Cear" option
- tb.clear_text();
+ Element::clear_text(&mut tb);
assert_eq!(tb.text, "");
assert_eq!(tb.edit_buffer, "");
}
@@ -1738,4 +1703,3 @@ mod tests {
crate::layout::set_textbox_multiline_border_width(1.0);
}
}
-
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 3fd809a..8f2d0d4 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -93,6 +93,27 @@ pub trait Layout {
false
}
+ /// Whether the adapter's hit test substitutes the base row rect (`row_x`/`row_w`, pushed in
+ /// by row-layout hosts via `set_row_rect`) plus the side-label inset — the legacy
+ /// `Element::hit_test` default geometry. Migrated controls so far dropped it (accepted
+ /// drift); TextBox restores it (cce-files' save-name box relies on row hits). Default: off,
+ /// keeping the other migrated widgets exactly as they shipped.
+ fn hit_row_rect(&self) -> bool {
+ false
+ }
+
+ /// Adjust a row-rect assignment before it lands on the base (`Element::set_row_rect` —
+ /// TextBox clamps the row width to its `width`/`max_width`). Default: identity.
+ fn adjust_row_rect(&self, x: f32, w: f32) -> (f32, f32) {
+ (x, w)
+ }
+
+ /// The final base rect landed from a `set_rect`, visible or not — unlike
+ /// [`arrange_children`](Layout::arrange_children), which the adapter gates on visibility.
+ /// TextBox caches it (its cursor/scroll math reads the laid-out rect between events) and
+ /// re-clamps its scroll, the legacy `set_rect` side effect. Default: ignore.
+ fn rect_assigned(&mut self, _rect: Rect) {}
+
// --- Container concern (transitional). Legacy containers own `Vec<*mut dyn Element>`
// children (child-arranging `set_rect` has no ctx to reach the tree) and every one
// hand-copies the same subtree plumbing: geometry/text aggregation, tick/popover/text-item
@@ -251,6 +272,21 @@ pub trait Paint {
fn text_bounds(&self, _rect: Rect) -> Option<[f32; 4]> {
None
}
+
+ /// Per-frame text shaping against the app's `FontSystem` (legacy `Element::prepare_text`
+ /// overrides). TextBox measures its glyph advances here — load-bearing for cursor↔pixel
+ /// mapping, not just a render cache. Receives the laid-out content rect. Default: nothing
+ /// to shape.
+ fn prepare_text(&mut self, _fs: &mut glyphon::FontSystem, _rect: Rect) {}
+
+ /// Whether the adapter re-enables the legacy shared focus/hover highlight overlay
+ /// (`Element::highlight_quad`'s default) for this widget. The adapter suppresses it for
+ /// migrated widgets — matching the `None` overrides most legacy controls carried — but
+ /// legacy TextBox kept the default: the focused editor gets the primary-highlight tint
+ /// over its background (data-editor's teal editing wash). Default: suppressed.
+ fn legacy_focus_highlight(&self) -> bool {
+ false
+ }
}
/// What an event handler may reach beyond its own state — the RFC §3.5 `EventCtx`, grown as
@@ -379,6 +415,43 @@ pub trait Input {
0
}
+ // --- Clipboard/selection surface (the context menu's Cut/Copy/Paste/Select-All actions
+ // call these on their target Element). The defaults replicate the `Element` defaults
+ // byte-for-byte (whole-value copy through the value-string pair), so widgets migrated
+ // before these hooks existed keep their exact behavior; TextBox overrides with real
+ // selection-aware implementations.
+
+ fn cut_selection(&mut self) -> bool {
+ if let Some(val) = self.value_string() {
+ crate::widget::clipboard::copy_to_clipboard(&val);
+ self.set_value_string("")
+ } else {
+ false
+ }
+ }
+ fn copy_selection(&self) {
+ if let Some(val) = self.value_string() {
+ crate::widget::clipboard::copy_to_clipboard(&val);
+ }
+ }
+ fn paste_from_clipboard(&mut self) -> bool {
+ if let Some(text) = crate::widget::clipboard::read_from_clipboard() {
+ self.set_value_string(&text)
+ } else {
+ false
+ }
+ }
+ fn select_all(&mut self) {}
+ fn clear_text(&mut self) {}
+
+ /// Whether direct `focus()`/`unfocus()` calls flip the base `focused` flag. Legacy widgets
+ /// differ: most set it in their `focus` overrides, but TextBox never did — its detached
+ /// label must not color as focused. Default: flip it (what every widget migrated so far
+ /// has shipped with).
+ fn tracks_base_focus(&self) -> bool {
+ true
+ }
+
/// Selection state pushed in by list/row hosts (legacy `Element::set_selected`).
fn set_selected(&mut self, _selected: bool) {}
@@ -873,6 +946,8 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
fn prepare_text(&mut self, fs: &mut glyphon::FontSystem) {
if self.visible() {
+ let rect = self.content_rect();
+ Paint::prepare_text(&mut self.inner, fs, rect);
for child in self.visible_children() {
unsafe { (*child).prepare_text(fs) };
}
@@ -928,6 +1003,10 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
self.base.y = r.y;
self.base.w = r.width;
self.base.h = r.height + inflation;
+ // Ungated rect notification (TextBox re-clamps scroll on every assignment, hidden or
+ // not — the legacy `set_rect` side effect).
+ let landed = Rect { x: self.base.x, y: self.base.y, width: self.base.w, height: self.base.h };
+ Layout::rect_assigned(&mut self.inner, landed);
// Containers position their children from the assigned rect (legacy `set_rect`
// overrides); hidden containers skip it, like the legacy impls.
if self.visible {
@@ -959,9 +1038,26 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
/// Narrow widgets own every pixel they draw through [`Paint::paint`]; the legacy shared
/// hover-highlight overlay is suppressed (matching what most control widgets' `None`
- /// overrides do today).
- fn highlight_quad(&self, _ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])> {
- None
+ /// overrides do today) — unless the widget opts back in
+ /// ([`Paint::legacy_focus_highlight`], TextBox), in which case this replicates the
+ /// `Element` default byte-for-byte: primary tint when ctx-focused (or active), secondary
+ /// when hovered, over the row-substituted, side-label-inset span.
+ fn highlight_quad(&self, ctx: &UiContext) -> Option<(f32, f32, f32, f32, [f32; 4])> {
+ if !Paint::legacy_focus_highlight(&self.inner) {
+ return None;
+ }
+ let is_focused = ctx.is_focused_addr(self as *const Self as *const () as usize);
+ let hc = if is_focused {
+ crate::colors::highlight_primary_color()
+ } else if self.base.hovered {
+ crate::colors::HIGHLIGHT_SECONDARY
+ } else {
+ return None;
+ };
+ let label_x = Element::label_x_offset(self);
+ let hx = if self.base.row_w > 0.0 { self.base.row_x } else { self.base.x } + label_x;
+ let hw = if self.base.row_w > 0.0 { self.base.row_w } else { self.base.w } - label_x;
+ Some((hx, self.base.y, hw, self.base.h, hc))
}
/// Report the *inner* type's name, not `Adapted<W>`: runtime type-name matching (e.g.
@@ -997,8 +1093,15 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
fn widget_font(&self) -> Option<String> {
Paint::widget_font(&self.inner)
}
- fn paint_self(&self, _ui: &UiContext, ctx: &mut PaintCtx) {
+ fn paint_self(&self, ui: &UiContext, ctx: &mut PaintCtx) {
Paint::paint(&self.inner, self.content_rect(), ctx);
+ // The legacy default `paint_self` drained `all_quads`, which carries the focus
+ // highlight — replicate for opt-in widgets, over the background (same draw order).
+ if let Some((hx, hy, hw, hh, hc)) = Element::highlight_quad(self, ui) {
+ if hc != crate::colors::HIGHLIGHT_SECONDARY {
+ ctx.quad(Rect { x: hx, y: hy, width: hw, height: hh }, hc);
+ }
+ }
// Inline-label widgets emit their own text in `paint`; detached labels come from the
// base, exactly as the legacy default `paint_self` emits them.
if !Layout::inline_label(&self.inner) {
@@ -1103,6 +1206,13 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
return Vec::new();
}
let mut quads = self.extra_quads();
+ // The `Element` default's highlight inclusion (secondary/hover tint excluded), live
+ // only for widgets that opt into the legacy overlay.
+ if let Some(hq) = Element::highlight_quad(self, ctx) {
+ if hq.4 != crate::colors::HIGHLIGHT_SECONDARY {
+ quads.push(hq);
+ }
+ }
if self.visible() {
// Container aggregation, replicating the shared legacy loop (Layer, Switcher):
// children contribute their plain quads, except a rounded-cornered child's
@@ -1177,6 +1287,29 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
fn set_selected(&mut self, selected: bool) {
Input::set_selected(&mut self.inner, selected)
}
+ fn cut_selection(&mut self) -> bool {
+ Input::cut_selection(&mut self.inner)
+ }
+ fn copy_selection(&self) {
+ Input::copy_selection(&self.inner)
+ }
+ fn paste_from_clipboard(&mut self) -> bool {
+ Input::paste_from_clipboard(&mut self.inner)
+ }
+ fn select_all(&mut self) {
+ Input::select_all(&mut self.inner)
+ }
+ fn clear_text(&mut self) {
+ Input::clear_text(&mut self.inner)
+ }
+ /// Row-rect assignment (row-layout hosts): apply the widget's clamp
+ /// ([`Layout::adjust_row_rect`] — TextBox's `width`/`max_width`), then the base write the
+ /// `Element` default does.
+ fn set_row_rect(&mut self, x: f32, w: f32) {
+ let (rx, rw) = Layout::adjust_row_rect(&self.inner, x, w);
+ self.base.row_x = rx;
+ self.base.row_w = rw;
+ }
fn draggable(&self) -> bool {
Input::draggable(&self.inner, self.content_rect())
}
@@ -1318,16 +1451,22 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
}
}
- /// Focus set/cleared directly (hosts call `w.focus()`/`w.unfocus()`): keep the base flag and
- /// tell the widget via the same `FocusIn`/`FocusOut` events the router would send.
+ /// Focus set/cleared directly (hosts call `w.focus()`/`w.unfocus()`): keep the base flag
+ /// (unless the widget opts out — [`Input::tracks_base_focus`], TextBox's legacy `focus`
+ /// never set it) and tell the widget via the same `FocusIn`/`FocusOut` events the router
+ /// would send.
fn focus(&mut self) {
- self.base.focused = true;
+ if Input::tracks_base_focus(&self.inner) {
+ self.base.focused = true;
+ }
let self_ptr = self.as_ptr_mut();
let mut ectx = EventCtx { rect: self.content_rect(), id: self.base.id(), ui: None, self_ptr: Some(self_ptr) };
Input::on_event(&mut self.inner, &Event::FocusIn, &mut ectx);
}
fn unfocus(&mut self) {
- self.base.focused = false;
+ if Input::tracks_base_focus(&self.inner) {
+ self.base.focused = false;
+ }
let self_ptr = self.as_ptr_mut();
let mut ectx = EventCtx { rect: self.content_rect(), id: self.base.id(), ui: None, self_ptr: Some(self_ptr) };
Input::on_event(&mut self.inner, &Event::FocusOut, &mut ectx);
@@ -1355,6 +1494,19 @@ impl<W: Layout + Paint + Input + 'static> Element for Adapted<W> {
return false;
}
let (x, y, w, h) = self.rect();
+ // Row-hit opt-in ([`Layout::hit_row_rect`]): replicate the legacy `hit_test` default's
+ // geometry — substitute the host-pushed row span and inset by the side label — before
+ // the narrow test. The width<=0 reject also comes from that default.
+ if Layout::hit_row_rect(&self.inner) {
+ if w <= 0.0 || h <= 0.0 {
+ return false;
+ }
+ let (mut hx, mut hw) = if self.base.row_w > 0.0 { (self.base.row_x, self.base.row_w) } else { (x, w) };
+ let label_x = Element::label_x_offset(self);
+ hx += label_x;
+ hw -= label_x;
+ return Input::hit(&self.inner, Rect { x: hx, y, width: hw, height: h }, px, py);
+ }
Input::hit(&self.inner, Rect { x, y, width: w, height: h }, px, py)
}