use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::Arc;
use web_time::Instant;
use crate::cursor::{set_cursor_icon, CursorIcon};
use crate::draw_ctx::DrawCtx;
use crate::event::{Event, EventResult, Key, Modifiers, MouseButton};
use crate::focus::FocusId;
use crate::geometry::{Point, Rect, Size};
use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
use crate::text::{measure_advance, measure_text_metrics, Font};
use crate::widget::{BackbufferCache, BackbufferMode, Widget};
use crate::widgets::scrollbar::ScrollbarAxis;
use crate::widgets::multi_click::{MultiClickTracker, SelectGranularity};
use crate::widgets::text_field_core::{
next_char_boundary, paragraph_range_at, prev_char_boundary, word_range_at, TextEditState,
};
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
pub enum TextHAlign {
#[default]
Left,
Center,
Right,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
pub enum TextVAlign {
#[default]
Top,
Center,
Bottom,
}
pub type KeyIntercept = dyn FnMut(&Key, &Modifiers) -> bool;
pub type LineHighlighter = dyn Fn(&str) -> Vec<(usize, usize, crate::color::Color)>;
fn clipboard_get() -> Option<String> {
crate::clipboard::get_text()
}
fn clipboard_set(text: &str) {
crate::clipboard::set_text(text);
}
#[derive(Clone, Debug)]
struct WrappedLine {
start: usize,
end: usize,
text: String,
hard_break: bool,
}
fn wrap_text_indexed(
font: &Arc<Font>,
text: &str,
font_size: f64,
max_width: f64,
) -> Vec<WrappedLine> {
let mut out: Vec<WrappedLine> = Vec::new();
let mut para_start = 0usize;
for (rel_end, chunk) in split_keep_newlines(text).enumerate() {
let _ = rel_end;
let para = chunk;
let para_abs_start = para_start;
let para_abs_end = para_abs_start + para.len();
let mut cursor = 0usize; let last_boundary = 0usize;
while cursor < para.len() {
let line_start = cursor;
let mut fit_end = line_start;
let mut last_word_end: Option<usize> = None;
let mut idx = line_start;
while idx < para.len() {
let next = next_char_boundary(para, idx);
let candidate = ¶[line_start..next];
let w = measure_text_metrics(font, candidate, font_size).width;
if w > max_width && fit_end > line_start {
break;
}
fit_end = next;
if next < para.len() {
let next_ch = para[next..].chars().next().unwrap_or(' ');
if next_ch.is_whitespace() {
last_word_end = Some(next);
}
}
idx = next;
}
let break_at = if fit_end < para.len() && last_word_end.is_some() {
last_word_end.unwrap()
} else {
fit_end.max(next_char_boundary(para, line_start))
};
let _ = last_boundary; let line_text = para[line_start..break_at].trim_end().to_string();
let abs_start = para_abs_start + line_start;
let abs_end = para_abs_start + break_at;
out.push(WrappedLine {
start: abs_start,
end: abs_end,
text: line_text,
hard_break: false,
});
let mut next_line_start = break_at;
while next_line_start < para.len() {
let ch = para[next_line_start..].chars().next().unwrap_or('x');
if !ch.is_whitespace() || ch == '\n' {
break;
}
next_line_start = next_char_boundary(para, next_line_start);
}
cursor = next_line_start;
if cursor >= para.len() {
break;
}
}
if out.is_empty() || out.last().map(|l| l.end).unwrap_or(0) != para_abs_end {
if para.is_empty() {
out.push(WrappedLine {
start: para_abs_start,
end: para_abs_end,
text: String::new(),
hard_break: false,
});
}
}
let source_end = para_abs_end + 1; let had_newline =
source_end <= text.len() && text.as_bytes().get(para_abs_end) == Some(&b'\n');
if had_newline {
if let Some(last) = out.last_mut() {
last.hard_break = true;
}
}
para_start = if had_newline {
source_end
} else {
para_abs_end
};
}
if out.is_empty() {
out.push(WrappedLine {
start: 0,
end: 0,
text: String::new(),
hard_break: false,
});
}
out
}
fn split_keep_newlines(text: &str) -> impl Iterator<Item = &str> + '_ {
text.split('\n')
}
#[derive(Clone, PartialEq)]
struct TextAreaSig {
epoch: u64,
cursor: usize,
anchor: usize,
focused: bool,
hovered: bool,
offset_bits: u64,
w_bits: u64,
h_bits: u64,
h_align: TextHAlign,
v_align: TextVAlign,
font_size_bits: u64,
}
pub struct TextArea {
bounds: Rect,
children: Vec<Box<dyn Widget>>, base: WidgetBase,
font: Arc<Font>,
font_size: f64,
padding: f64,
hint: String,
content_h_align: TextHAlign,
content_v_align: TextVAlign,
h_align_cell: Option<Rc<Cell<TextHAlign>>>,
v_align_cell: Option<Rc<Cell<TextVAlign>>>,
focus_request_id: Option<FocusId>,
on_key_chord: Option<Rc<RefCell<KeyIntercept>>>,
highlighter: Option<Rc<LineHighlighter>>,
on_change: Option<Box<dyn FnMut(&str)>>,
edit: Rc<RefCell<TextEditState>>,
cached_wrap_width: f64,
cached_lines: Vec<WrappedLine>,
cached_line_h: f64,
cached_epoch: u64,
vbar: ScrollbarAxis,
last_layout_cursor: Option<usize>,
focused: bool,
hovered: bool,
selecting_drag: bool,
focus_time: Option<Instant>,
blink_last_phase: Cell<u64>,
multi_click: MultiClickTracker,
select_granularity: SelectGranularity,
select_pivot: (usize, usize),
cache: BackbufferCache,
last_sig: Option<TextAreaSig>,
context_menu: crate::widgets::text_context_menu::TextContextMenu,
context_menu_enabled: bool,
}
impl TextArea {
pub fn new(font: Arc<Font>) -> Self {
Self {
bounds: Rect::default(),
children: Vec::new(),
base: WidgetBase::new(),
font,
font_size: 13.0,
padding: 8.0,
hint: "Type here…".to_string(),
content_h_align: TextHAlign::Left,
content_v_align: TextVAlign::Top,
h_align_cell: None,
v_align_cell: None,
focus_request_id: None,
on_key_chord: None,
highlighter: None,
on_change: None,
edit: Rc::new(RefCell::new(TextEditState::default())),
cached_wrap_width: -1.0,
cached_lines: Vec::new(),
cached_line_h: 0.0,
cached_epoch: 0,
vbar: ScrollbarAxis {
enabled: true,
..ScrollbarAxis::default()
},
last_layout_cursor: None,
focused: false,
hovered: false,
selecting_drag: false,
focus_time: None,
blink_last_phase: Cell::new(0),
multi_click: MultiClickTracker::default(),
select_granularity: SelectGranularity::default(),
select_pivot: (0, 0),
cache: BackbufferCache::default(),
last_sig: None,
context_menu: crate::widgets::text_context_menu::TextContextMenu::new(),
context_menu_enabled: true,
}
}
fn cache_sig(&self) -> TextAreaSig {
let st = self.edit.borrow();
TextAreaSig {
epoch: st.epoch,
cursor: st.cursor,
anchor: st.anchor,
focused: self.focused,
hovered: self.hovered,
offset_bits: self.vbar.offset.to_bits(),
w_bits: self.bounds.width.to_bits(),
h_bits: self.bounds.height.to_bits(),
h_align: self.resolved_h_align(),
v_align: self.resolved_v_align(),
font_size_bits: self.font_size.to_bits(),
}
}
pub fn with_text(self, text: impl Into<String>) -> Self {
let t: String = text.into();
let cursor = t.len();
let epoch = self.edit.borrow().epoch.wrapping_add(1);
*self.edit.borrow_mut() = TextEditState {
text: t,
cursor,
anchor: cursor,
epoch,
};
self
}
pub fn with_hint_text(mut self, hint: impl Into<String>) -> Self {
self.hint = hint.into();
self
}
pub fn with_content_h_align(mut self, a: TextHAlign) -> Self {
self.content_h_align = a;
self
}
pub fn with_content_v_align(mut self, a: TextVAlign) -> Self {
self.content_v_align = a;
self
}
pub fn with_h_align_cell(mut self, cell: Rc<Cell<TextHAlign>>) -> Self {
self.h_align_cell = Some(cell);
self
}
pub fn with_v_align_cell(mut self, cell: Rc<Cell<TextVAlign>>) -> Self {
self.v_align_cell = Some(cell);
self
}
pub fn with_edit_state(mut self, state: Rc<RefCell<TextEditState>>) -> Self {
self.edit = state;
self.cached_wrap_width = -1.0;
self
}
pub fn with_focus_id(mut self, id: FocusId) -> Self {
self.focus_request_id = Some(id);
self
}
pub fn with_key_intercept(
mut self,
cb: impl FnMut(&Key, &Modifiers) -> bool + 'static,
) -> Self {
self.on_key_chord = Some(Rc::new(RefCell::new(cb)));
self
}
pub fn with_highlighter(
mut self,
cb: impl Fn(&str) -> Vec<(usize, usize, crate::color::Color)> + 'static,
) -> Self {
self.highlighter = Some(Rc::new(cb));
self
}
pub fn edit_state(&self) -> Rc<RefCell<TextEditState>> {
Rc::clone(&self.edit)
}
pub fn selection(&self) -> Option<(usize, usize)> {
self.edit.borrow().selection_range()
}
pub fn selected_text(&self) -> String {
let st = self.edit.borrow();
match st.selection_range() {
Some((lo, hi)) => st.text[lo..hi].to_string(),
None => String::new(),
}
}
pub fn set_cursor_to_start(&mut self) {
let mut st = self.edit.borrow_mut();
st.cursor = 0;
st.anchor = 0;
}
pub fn set_cursor_to_end(&mut self) {
let mut st = self.edit.borrow_mut();
let end = st.text.len();
st.cursor = end;
st.anchor = end;
}
pub fn with_font_size(mut self, size: f64) -> Self {
self.font_size = size;
self
}
pub fn with_padding(mut self, p: f64) -> Self {
self.padding = p;
self
}
pub fn with_margin(mut self, m: Insets) -> Self {
self.base.margin = m;
self
}
pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
self.base.h_anchor = h;
self
}
pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
self.base.v_anchor = v;
self
}
pub fn with_min_size(mut self, s: Size) -> Self {
self.base.min_size = s;
self
}
pub fn with_max_size(mut self, s: Size) -> Self {
self.base.max_size = s;
self
}
pub fn text(&self) -> String {
self.edit.borrow().text.clone()
}
pub fn cursor(&self) -> usize {
self.edit.borrow().cursor
}
pub fn visual_line_count(&self) -> usize {
self.cached_lines.len()
}
fn refresh_wrap(&mut self, inner_w: f64) {
let st = self.edit.borrow();
let same_width = (self.cached_wrap_width - inner_w).abs() < 0.5;
let same_epoch = self.cached_epoch == st.epoch;
if same_width && same_epoch && !self.cached_lines.is_empty() {
return;
}
let lines = wrap_text_indexed(&self.font, &st.text, self.font_size, inner_w.max(1.0));
self.cached_lines = lines;
self.cached_wrap_width = inner_w;
self.cached_epoch = st.epoch;
self.cached_line_h = self.font_size * 1.35;
}
fn mark_dirty(&mut self) {
self.cached_wrap_width = -1.0;
}
fn resolved_h_align(&self) -> TextHAlign {
self.h_align_cell
.as_ref()
.map(|c| c.get())
.unwrap_or(self.content_h_align)
}
fn resolved_v_align(&self) -> TextVAlign {
self.v_align_cell
.as_ref()
.map(|c| c.get())
.unwrap_or(self.content_v_align)
}
fn inner_width(&self) -> f64 {
(self.bounds.width - self.padding * 2.0).max(0.0)
}
fn v_align_shift(&self) -> f64 {
let content_h = self.cached_lines.len() as f64 * self.cached_line_h;
let inner_h = (self.bounds.height - self.padding * 2.0).max(0.0);
let slack = (inner_h - content_h).max(0.0);
match self.resolved_v_align() {
TextVAlign::Top => 0.0,
TextVAlign::Center => slack * 0.5,
TextVAlign::Bottom => slack,
}
}
fn content_top_y(&self) -> f64 {
self.bounds.height - self.padding - self.v_align_shift() + self.vbar.offset
}
fn line_top_y(&self, i: usize) -> f64 {
self.content_top_y() - i as f64 * self.cached_line_h
}
fn line_x_start(&self, line: &WrappedLine) -> f64 {
let line_w = measure_advance(&self.font, &line.text, self.font_size);
let slack = (self.inner_width() - line_w).max(0.0);
let shift = match self.resolved_h_align() {
TextHAlign::Left => 0.0,
TextHAlign::Center => slack * 0.5,
TextHAlign::Right => slack,
};
self.padding + shift
}
fn line_for_cursor(&self, byte_pos: usize) -> usize {
for (i, l) in self.cached_lines.iter().enumerate() {
if byte_pos >= l.start && byte_pos <= l.end {
return i;
}
}
self.cached_lines.len().saturating_sub(1)
}
fn byte_offset_at(&self, local: Point) -> usize {
if self.cached_lines.is_empty() || self.cached_line_h <= 0.0 {
return 0;
}
let rel_from_top = self.content_top_y() - local.y;
let mut line_idx = (rel_from_top / self.cached_line_h).floor() as isize;
if line_idx < 0 {
line_idx = 0;
}
if line_idx as usize >= self.cached_lines.len() {
line_idx = self.cached_lines.len() as isize - 1;
}
let line = &self.cached_lines[line_idx as usize];
let pad_x = self.line_x_start(line);
let rel_x = (local.x - pad_x).max(0.0);
let txt = &line.text;
let mut best_byte = 0usize;
let mut best_delta = f64::INFINITY;
let mut acc = 0.0_f64;
let mut prev_byte = 0usize;
for (i, _c) in txt.char_indices().chain(std::iter::once((txt.len(), ' '))) {
let w_here = if i > prev_byte {
measure_advance(&self.font, &txt[prev_byte..i], self.font_size)
} else {
0.0
};
acc += w_here;
let d = (acc - rel_x).abs();
if d < best_delta {
best_delta = d;
best_byte = i;
}
prev_byte = i;
}
line.start + best_byte
}
fn pos_for_cursor(&self, byte_pos: usize) -> Point {
if self.cached_lines.is_empty() {
return Point::ORIGIN;
}
let line_idx = self.line_for_cursor(byte_pos);
let line = &self.cached_lines[line_idx];
let offset = byte_pos.saturating_sub(line.start).min(line.text.len());
let x = self.line_x_start(line)
+ measure_advance(&self.font, &line.text[..offset], self.font_size);
let line_top = self.line_top_y(line_idx);
let line_bottom = line_top - self.cached_line_h;
Point::new(x, line_bottom)
}
fn line_baseline_y(&self, i: usize) -> f64 {
let ascent = self.font.ascender_px(self.font_size);
let descent = self.font.descender_px(self.font_size);
let line_top = self.line_top_y(i);
let line_bottom = line_top - self.cached_line_h;
let baseline = line_bottom + (self.cached_line_h - (ascent + descent)) * 0.5 + descent;
crate::font_settings::snap_baseline_y(baseline)
}
}
mod callbacks;
mod context_menu;
mod edit_ops;
mod scroll;
mod widget_impl;
#[cfg(test)]
mod selection_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod redraw_tests;