use std::cell::{Cell, RefCell};
use crate::anim::{Easing, Lerp, Transition};
use crate::core::{ClickFn, EventCtx, Widget};
use crate::event::{CursorShape, Event, Key, KeyEvent, MenuItem, MouseButton, PointerKind};
use crate::geometry::{Rect, Size};
use crate::render::{Canvas, Paint};
use crate::signal::Signal;
use crate::spec::Align;
use crate::style::Style;
use crate::text::TextEngine;
use crate::theme::Intent;
use crate::ui::containers::VScrollbar;
const BOX_SIZE: i32 = 18;
const BOX_SIZE_SMALL: i32 = 14;
const GAP: i32 = 8;
const GAP_SMALL: i32 = 6;
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum CheckBoxSize {
#[default]
Normal,
Small,
}
pub struct CheckBox {
label: String,
state: Signal<bool>,
fill: Cell<Transition<f32>>,
on_toggle: Option<ClickFn>,
intent: Intent,
size: CheckBoxSize,
}
impl CheckBox {
pub fn new(label: String, state: Signal<bool>) -> Self {
let init = if state.get() { 1.0 } else { 0.0 };
Self {
label,
state,
fill: Cell::new(Transition::new(init)),
on_toggle: None,
intent: Intent::Primary,
size: CheckBoxSize::Normal,
}
}
pub fn set_intent(&mut self, intent: Intent) {
self.intent = intent;
}
pub fn set_size(&mut self, size: CheckBoxSize) {
self.size = size;
}
fn font_size(&self, style: &Style) -> f32 {
match self.size {
CheckBoxSize::Normal => style.font_size,
CheckBoxSize::Small => (style.font_size - 2.0).max(10.0),
}
}
fn toggle(&mut self, ctx: &mut EventCtx) {
if let Some(cb) = self.on_toggle.as_mut() {
cb(ctx);
} else {
self.state.set(!self.state.get());
}
ctx.mark_dirty();
}
}
impl Widget for CheckBox {
fn measure(&self, _avail: Size, style: &Style, text: &mut dyn TextEngine) -> Size {
let (bsz, gap) = match self.size {
CheckBoxSize::Normal => (BOX_SIZE, GAP),
CheckBoxSize::Small => (BOX_SIZE_SMALL, GAP_SMALL),
};
let fsize = self.font_size(style);
let t = text.measure(&self.label, style.font_family.as_deref(), fsize, None);
Size::new(bsz + gap + t.w, bsz.max(t.h))
}
fn paint(
&self,
bounds: Rect,
_content: Rect,
_focused: bool,
enabled: bool,
canvas: &mut dyn Canvas,
style: &Style,
) {
let th = crate::theme::current();
let (p, tg) = (&th.palette, &th.toggle);
let (base_accent, check_fg) = match self.intent {
Intent::Primary => (tg.accent(p), p.on_accent),
other => {
let ic = other.colors(p);
(ic.bg, ic.fg)
}
};
let accent = if enabled { base_accent } else { p.track };
let text_color = if !enabled {
p.text_disabled
} else if style.fg_role.is_some() {
style.resolved_fg(&th)
} else {
style.fg
};
let (bsz_i, gap_i, radius, stroke_w, check_stroke) = match self.size {
CheckBoxSize::Normal => (BOX_SIZE, GAP, 4.0_f32, 1.5_f32, 2.0_f32),
CheckBoxSize::Small => (BOX_SIZE_SMALL, GAP_SMALL, 3.0_f32, 1.0_f32, 1.5_f32),
};
let cy = bounds.y + (bounds.h - bsz_i) / 2;
let (bx, by) = (bounds.x as f32, cy as f32);
let mut fill = self.fill.get();
let target = if self.state.get() { 1.0 } else { 0.0 };
if fill.target() != target {
fill.retarget(target, th.anim.fast(), Easing::EaseOut);
}
let amount = fill.animate();
self.fill.set(fill);
let sz = bsz_i as f32;
canvas.fill_round_rect(
bx,
by,
sz,
sz,
radius,
&Paint::fill(tg.knob(p).lerp(accent, amount)),
);
if amount < 1.0 {
canvas.stroke_round_rect(
bx,
by,
sz,
sz,
radius,
stroke_w,
&Paint::fill(tg.track(p).scale_alpha(1.0 - amount)),
);
}
if amount > 0.0 {
let check = if enabled { check_fg } else { p.on_accent };
let paint = Paint::fill(check.scale_alpha(amount));
let s = sz / 18.0; canvas.draw_line(
bx + 4.0 * s,
by + 9.0 * s,
bx + 8.0 * s,
by + 13.0 * s,
check_stroke,
&paint,
);
canvas.draw_line(
bx + 8.0 * s,
by + 13.0 * s,
bx + 14.0 * s,
by + 5.0 * s,
check_stroke,
&paint,
);
}
let text_rect = Rect::new(
bounds.x + bsz_i + gap_i,
bounds.y,
bounds.w - bsz_i - gap_i,
bounds.h,
);
canvas.draw_text(
&self.label,
text_rect,
text_color,
Align::Start,
style.font_family.as_deref(),
self.font_size(style),
);
}
fn on_event(&mut self, ctx: &mut EventCtx, ev: &Event) -> bool {
match ev {
Event::Pointer(p) if p.kind == PointerKind::Up => {
if ctx.bounds().contains(p.pos) {
self.toggle(ctx);
}
true
}
Event::Pointer(p) if p.kind == PointerKind::Down => {
ctx.request_focus();
true
}
Event::Key(k) if k.pressed && (k.key == Key::Space || k.key == Key::Enter) => {
self.toggle(ctx);
true
}
_ => false,
}
}
fn focusable(&self) -> bool {
true
}
fn take_click(&mut self, f: ClickFn) {
self.on_toggle = Some(f);
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
pub struct Switch {
state: Signal<bool>,
pos: Cell<Transition<f32>>,
}
impl Switch {
pub fn new(state: Signal<bool>) -> Self {
let init = if state.get() { 1.0 } else { 0.0 };
Self {
state,
pos: Cell::new(Transition::new(init)),
}
}
fn toggle(&self, ctx: &mut EventCtx) {
self.state.set(!self.state.get());
ctx.mark_dirty();
}
}
impl Widget for Switch {
fn measure(&self, _avail: Size, _style: &Style, _text: &mut dyn TextEngine) -> Size {
Size::new(44, 24)
}
fn paint(
&self,
bounds: Rect,
_content: Rect,
_focused: bool,
enabled: bool,
canvas: &mut dyn Canvas,
_style: &Style,
) {
let h = 24.min(bounds.h);
let w = 44.min(bounds.w);
let x = bounds.x as f32;
let y = (bounds.y + (bounds.h - h) / 2) as f32;
let on = self.state.get();
let th = crate::theme::current();
let (p, tg) = (&th.palette, &th.toggle);
let mut pos = self.pos.get();
let target = if on { 1.0 } else { 0.0 };
if pos.target() != target {
pos.retarget(target, th.anim.normal(), Easing::EaseInOut);
}
let amount = pos.animate();
self.pos.set(pos);
let track = if !enabled {
p.track
} else {
tg.track(p).lerp(tg.accent(p), amount)
};
canvas.fill_round_rect(
x,
y,
w as f32,
h as f32,
h as f32 / 2.0,
&Paint::fill(track),
);
let r = (h - 6) as f32 / 2.0;
let (off_cx, on_cx) = (x + 3.0 + r, x + w as f32 - 3.0 - r);
let knob_cx = off_cx.lerp(on_cx, amount);
canvas.fill_circle(knob_cx, y + h as f32 / 2.0, r, &Paint::fill(tg.knob(p)));
}
fn on_event(&mut self, ctx: &mut EventCtx, ev: &Event) -> bool {
match ev {
Event::Pointer(p) if p.kind == PointerKind::Up => {
if ctx.bounds().contains(p.pos) {
self.toggle(ctx);
}
true
}
Event::Pointer(p) if p.kind == PointerKind::Down => {
ctx.request_focus();
true
}
Event::Key(k) if k.pressed && (k.key == Key::Space || k.key == Key::Enter) => {
self.toggle(ctx);
true
}
_ => false,
}
}
fn focusable(&self) -> bool {
true
}
}
pub struct RadioButton {
label: String,
group: Signal<usize>,
index: usize,
sel: Cell<Transition<f32>>,
}
impl RadioButton {
pub fn new(label: String, group: Signal<usize>, index: usize) -> Self {
let init = if group.get() == index { 1.0 } else { 0.0 };
Self {
label,
group,
index,
sel: Cell::new(Transition::new(init)),
}
}
fn selected(&self) -> bool {
self.group.get() == self.index
}
fn select(&self, ctx: &mut EventCtx) {
self.group.set(self.index);
ctx.mark_dirty_all();
}
}
impl Widget for RadioButton {
fn measure(&self, _avail: Size, style: &Style, text: &mut dyn TextEngine) -> Size {
let t = text.measure(
&self.label,
style.font_family.as_deref(),
style.font_size,
None,
);
Size::new(BOX_SIZE + GAP + t.w, BOX_SIZE.max(t.h))
}
fn paint(
&self,
bounds: Rect,
_content: Rect,
_focused: bool,
enabled: bool,
canvas: &mut dyn Canvas,
style: &Style,
) {
let th = crate::theme::current();
let (p, tg) = (&th.palette, &th.toggle);
let accent = if enabled { tg.accent(p) } else { p.track };
let text_color = if !enabled {
p.text_disabled
} else {
style.resolved_fg(&th)
};
let cy = bounds.y + bounds.h / 2;
let cx = bounds.x + BOX_SIZE / 2;
let outer = BOX_SIZE as f32 / 2.0;
let mut sel = self.sel.get();
let target = if self.selected() { 1.0 } else { 0.0 };
if sel.target() != target {
sel.retarget(target, th.anim.fast(), Easing::EaseOut);
}
let amount = sel.animate();
self.sel.set(sel);
let (cxf, cyf) = (cx as f32, cy as f32);
canvas.fill_circle(
cxf,
cyf,
outer,
&Paint::fill(tg.track(p).lerp(accent, amount)),
);
canvas.fill_circle(
cxf,
cyf,
outer - 1.5f32.lerp(5.0, amount),
&Paint::fill(tg.knob(p)),
);
if amount > 0.0 {
canvas.fill_circle(cxf, cyf, (outer - 8.0) * amount, &Paint::fill(accent));
}
let text_rect = Rect::new(
bounds.x + BOX_SIZE + GAP,
bounds.y,
bounds.w - BOX_SIZE - GAP,
bounds.h,
);
canvas.draw_text(
&self.label,
text_rect,
text_color,
Align::Start,
style.font_family.as_deref(),
style.font_size,
);
}
fn on_event(&mut self, ctx: &mut EventCtx, ev: &Event) -> bool {
match ev {
Event::Pointer(p) if p.kind == PointerKind::Up => {
if ctx.bounds().contains(p.pos) {
self.select(ctx);
}
true
}
Event::Pointer(p) if p.kind == PointerKind::Down => {
ctx.request_focus();
true
}
Event::Key(k) if k.pressed && (k.key == Key::Space || k.key == Key::Enter) => {
self.select(ctx);
true
}
_ => false,
}
}
fn focusable(&self) -> bool {
true
}
}
const VALUE_LABEL_W: i32 = 44;
pub struct Slider {
value: Signal<f32>, dragging: bool,
pub show_value: bool,
}
impl Slider {
pub fn new(value: Signal<f32>) -> Self {
Self {
value,
dragging: false,
show_value: false,
}
}
pub fn set_show_value(&mut self, on: bool) {
self.show_value = on;
}
fn set_from_pos(&self, ctx: &mut EventCtx, x: i32) {
let b = ctx.bounds();
let r = KNOB_R;
let usable = (b.w - 2 * r).max(1);
let v = ((x - b.x - r) as f32 / usable as f32).clamp(0.0, 1.0);
self.value.set(v);
ctx.mark_dirty();
}
}
const KNOB_R: i32 = 9;
impl Widget for Slider {
fn measure(&self, _avail: Size, style: &Style, _text: &mut dyn TextEngine) -> Size {
let extra = if self.show_value { VALUE_LABEL_W } else { 0 };
let h = if self.show_value {
(2 * KNOB_R).max(style.font_size as i32 + 4)
} else {
2 * KNOB_R
};
Size::new(120 + extra, h)
}
fn paint(
&self,
bounds: Rect,
_content: Rect,
_focused: bool,
enabled: bool,
canvas: &mut dyn Canvas,
style: &Style,
) {
let v = self.value.get().clamp(0.0, 1.0);
let th = crate::theme::current();
let (pal, tg) = (&th.palette, &th.toggle);
let accent = if enabled { tg.accent(pal) } else { pal.track };
let track_w = if self.show_value {
bounds.w - VALUE_LABEL_W
} else {
bounds.w
};
let cy = bounds.y as f32 + bounds.h as f32 / 2.0;
let r = KNOB_R as f32;
let x0 = bounds.x as f32 + r;
let x1 = bounds.x as f32 + track_w as f32 - r;
let knob_x = x0 + (x1 - x0) * v;
canvas.fill_round_rect(
x0,
cy - 2.0,
(x1 - x0).max(0.0),
4.0,
2.0,
&Paint::fill(tg.track(pal)),
);
canvas.fill_round_rect(
x0,
cy - 2.0,
(knob_x - x0).max(0.0),
4.0,
2.0,
&Paint::fill(accent),
);
canvas.fill_circle(knob_x, cy, r, &Paint::fill(tg.knob(pal)));
canvas.fill_circle(knob_x, cy, r - 2.0, &Paint::fill(accent));
if self.show_value {
let label = format!("{:.0}%", v * 100.0);
let label_rect = Rect::new(bounds.x + track_w, bounds.y, VALUE_LABEL_W, bounds.h);
let text_color = if enabled { pal.text } else { pal.text_disabled };
canvas.draw_text(
&label,
label_rect,
text_color,
crate::spec::Align::Center,
style.font_family.as_deref(),
style.font_size,
);
}
}
fn on_event(&mut self, ctx: &mut EventCtx, ev: &Event) -> bool {
match ev {
Event::Pointer(p) => match p.kind {
PointerKind::Down => {
ctx.request_focus();
ctx.capture();
self.dragging = true;
self.set_from_pos(ctx, p.pos.x);
true
}
PointerKind::Move if self.dragging => {
self.set_from_pos(ctx, p.pos.x);
true
}
PointerKind::Up => {
self.dragging = false;
ctx.release_capture();
true
}
_ => false,
},
Event::Key(k) if k.pressed => {
let step = 0.05;
let v = self.value.get();
match k.key {
Key::Left => {
self.value.set((v - step).max(0.0));
ctx.mark_dirty();
true
}
Key::Right => {
self.value.set((v + step).min(1.0));
ctx.mark_dirty();
true
}
_ => false,
}
}
_ => false,
}
}
fn focusable(&self) -> bool {
true
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
const TEXT_PAD: i32 = 10;
const NO_WRAP_W: i32 = 100_000;
const SEL_EOL_EXTRA: i32 = 6;
const PASSWORD_MASK: char = '\u{2022}';
#[derive(Clone, Copy)]
pub struct TextConfig {
pub multiline: bool,
pub password: bool,
pub wrap: bool,
pub leading: Option<char>,
}
impl Default for TextConfig {
fn default() -> Self {
Self {
multiline: false,
password: false,
wrap: true,
leading: None,
}
}
}
struct VisLine {
start: usize,
end: usize,
x: Vec<i32>,
hard: bool,
}
#[derive(Default)]
struct TextLayout {
lines: Vec<VisLine>,
line_h: i32,
key: Option<(String, i32, Option<String>, u32)>,
}
pub struct TextInput {
text: Signal<String>,
placeholder: String,
config: TextConfig,
cursor: usize, anchor: Option<usize>, scroll_x: Cell<i32>, scroll_y: Cell<i32>, goal_x: Cell<Option<i32>>,
layout: RefCell<TextLayout>, caret_local: Cell<Option<(i32, i32, i32)>>,
dragging: bool,
scrollbar: VScrollbar,
follow_cursor: Cell<bool>,
hover_in_scrollbar: Cell<bool>,
font_size_hint: Cell<f32>,
}
impl TextInput {
pub fn new(text: Signal<String>, placeholder: String) -> Self {
let cursor = text.with(|t| t.chars().count());
Self {
text,
placeholder,
config: TextConfig::default(),
cursor,
anchor: None,
scroll_x: Cell::new(0),
scroll_y: Cell::new(0),
goal_x: Cell::new(None),
layout: RefCell::new(TextLayout::default()),
caret_local: Cell::new(None),
dragging: false,
scrollbar: VScrollbar::new(),
follow_cursor: Cell::new(true),
hover_in_scrollbar: Cell::new(false),
font_size_hint: Cell::new(14.0),
}
}
pub fn config_mut(&mut self) -> &mut TextConfig {
&mut self.config
}
fn is_multiline(&self) -> bool {
self.config.multiline && !self.config.password
}
fn lead_inset(&self, fsize: f32) -> i32 {
if self.config.leading.is_some() {
(fsize as i32) + 8
} else {
0
}
}
fn char_count(&self) -> usize {
self.text.with(|t| t.chars().count())
}
fn display_string(&self) -> String {
self.text.with(|t| {
if self.config.password {
t.chars().map(|_| PASSWORD_MASK).collect()
} else {
t.clone()
}
})
}
fn clamp_cursor(&mut self) {
let n = self.char_count();
if self.cursor > n {
self.cursor = n;
}
}
fn selection(&self) -> Option<(usize, usize)> {
let n = self.char_count();
let a = self.anchor?.min(n);
let c = self.cursor.min(n);
if a == c {
None
} else {
Some((a.min(c), a.max(c)))
}
}
fn delete_selection(&mut self, ctx: &mut EventCtx) -> bool {
if let Some((s, e)) = self.selection() {
self.text.update(|t| {
let bs = char_to_byte(t, s);
let be = char_to_byte(t, e);
t.replace_range(bs..be, "");
});
self.cursor = s;
self.anchor = None;
self.goal_x.set(None);
ctx.mark_dirty();
true
} else {
false
}
}
fn type_char(&mut self, ctx: &mut EventCtx, c: char) {
if c.is_control() {
return;
}
self.delete_selection(ctx);
self.clamp_cursor();
let cursor = self.cursor;
self.text.update(|s| {
let byte = char_to_byte(s, cursor);
s.insert(byte, c);
});
self.cursor += 1;
self.anchor = None;
self.goal_x.set(None);
ctx.mark_dirty();
}
fn backspace(&mut self, ctx: &mut EventCtx) {
self.clamp_cursor();
if self.cursor == 0 {
return;
}
let cursor = self.cursor;
self.text.update(|s| {
let start = char_to_byte(s, cursor - 1);
let end = char_to_byte(s, cursor);
s.replace_range(start..end, "");
});
self.cursor -= 1;
self.goal_x.set(None);
ctx.mark_dirty();
}
fn delete_forward(&mut self, ctx: &mut EventCtx) {
self.clamp_cursor();
let len = self.char_count();
if self.cursor >= len {
return;
}
let cursor = self.cursor;
self.text.update(|s| {
let start = char_to_byte(s, cursor);
let end = char_to_byte(s, cursor + 1);
s.replace_range(start..end, "");
});
self.goal_x.set(None);
ctx.mark_dirty();
}
fn move_to(&mut self, ctx: &mut EventCtx, target: usize, shift: bool) {
if shift {
if self.anchor.is_none() {
self.anchor = Some(self.cursor);
}
} else {
self.anchor = None;
}
self.cursor = target.min(self.char_count());
self.goal_x.set(None);
ctx.mark_dirty();
}
fn select_word(&mut self, idx: usize) {
let chars: Vec<char> = self.text.with(|t| t.chars().collect());
let (s, e) = word_run(&chars, idx);
self.anchor = Some(s.min(chars.len()));
self.cursor = e.min(chars.len());
}
fn select_all(&mut self) {
self.anchor = Some(0);
self.cursor = self.char_count();
}
fn context_menu_items(&self) -> Vec<MenuItem> {
let has_sel = self.selection().is_some();
let has_text = self.char_count() > 0;
let pw = self.config.password;
let ctrl = |vk: u32| KeyEvent {
key: Key::Other(vk),
pressed: true,
shift: false,
ctrl: true,
};
vec![
MenuItem::key("剪切", ctrl(0x58), has_sel && !pw), MenuItem::key("复制", ctrl(0x43), has_sel && !pw), MenuItem::key("粘贴", ctrl(0x56), true), MenuItem::key("全选", ctrl(0x41), has_text), ]
}
fn select_para(&mut self, idx: usize) {
let chars: Vec<char> = self.text.with(|t| t.chars().collect());
let n = chars.len();
let i = idx.min(n);
let mut s = i;
while s > 0 && chars[s - 1] != '\n' {
s -= 1;
}
let mut e = i;
while e < n && chars[e] != '\n' {
e += 1;
}
self.anchor = Some(s);
self.cursor = e;
}
fn rebuild_layout(
&self,
canvas: &mut dyn Canvas,
disp: &str,
family: Option<&str>,
fsize: f32,
inner_w: i32,
) {
{
let lay = self.layout.borrow();
if let Some((k_disp, k_w, k_fam, k_size)) = lay.key.as_ref() {
if k_disp == disp
&& *k_w == inner_w
&& k_fam.as_deref() == family
&& *k_size == fsize.to_bits()
{
return;
}
}
}
let chars: Vec<char> = disp.chars().collect();
let n = chars.len();
let multiline = self.is_multiline();
let wrap = self.config.wrap && multiline;
let mut lay = self.layout.borrow_mut();
lay.key = Some((
disp.to_string(),
inner_w,
family.map(str::to_string),
fsize.to_bits(),
));
lay.lines.clear();
lay.line_h = canvas.measure_text("Ay", family, fsize).h.max(fsize as i32);
let mut p = 0usize;
loop {
let q = if multiline {
(p..n).find(|&i| chars[i] == '\n').unwrap_or(n)
} else {
n
};
let mut prefix = Vec::with_capacity(q - p + 1);
prefix.push(0);
let mut acc = String::new();
for &ch in &chars[p..q] {
acc.push(ch);
prefix.push(canvas.measure_text(&acc, family, fsize).w);
}
for (ls, le, hard) in wrap_paragraph(&chars, p, q, &prefix, inner_w, wrap) {
let base = prefix[ls - p];
let x: Vec<i32> = (ls..=le).map(|k| prefix[k - p] - base).collect();
lay.lines.push(VisLine {
start: ls,
end: le,
x,
hard,
});
}
if q < n {
p = q + 1; } else {
break;
}
}
}
fn cursor_line(&self, lay: &TextLayout, c: usize) -> usize {
let lines = &lay.lines;
for (i, ln) in lines.iter().enumerate() {
if c < ln.end {
return i;
}
if c == ln.end && (ln.hard || i + 1 == lines.len()) {
return i;
}
}
lines.len().saturating_sub(1)
}
fn caret_line_x(&self, lay: &TextLayout, c: usize) -> (usize, i32) {
if lay.lines.is_empty() {
return (0, 0);
}
let li = self.cursor_line(lay, c);
let ln = &lay.lines[li];
let col = c.saturating_sub(ln.start).min(ln.x.len().saturating_sub(1));
(li, ln.x.get(col).copied().unwrap_or(0))
}
fn pos_to_index(&self, ctx: &EventCtx, screen_x: i32, screen_y: i32) -> usize {
let lay = self.layout.borrow();
if lay.lines.is_empty() {
return 0;
}
let b = ctx.bounds();
let lead = self.lead_inset(self.font_size_hint.get());
let local_x = screen_x - (b.x + TEXT_PAD + lead) + self.scroll_x.get();
let local_y = screen_y - (b.y + TEXT_PAD) + self.scroll_y.get();
let li = if lay.line_h > 0 {
(local_y / lay.line_h).clamp(0, lay.lines.len() as i32 - 1) as usize
} else {
0
};
let ln = &lay.lines[li];
let mut best = 0;
let mut best_d = i32::MAX;
for (j, &v) in ln.x.iter().enumerate() {
let d = (v - local_x).abs();
if d < best_d {
best_d = d;
best = j;
}
}
ln.start + best
}
fn move_vertical(&mut self, ctx: &mut EventCtx, down: bool, shift: bool) {
let lay = self.layout.borrow();
if lay.lines.is_empty() {
return;
}
let (li, cur_x) = self.caret_line_x(&lay, self.cursor.min(self.char_count()));
let goal = self.goal_x.get().unwrap_or(cur_x);
let target_li = if down {
if li + 1 >= lay.lines.len() {
drop(lay);
self.goal_x.set(Some(goal));
return;
}
li + 1
} else {
if li == 0 {
drop(lay);
self.goal_x.set(Some(goal));
return;
}
li - 1
};
let ln = &lay.lines[target_li];
let mut best = 0;
let mut best_d = i32::MAX;
for (j, &v) in ln.x.iter().enumerate() {
let d = (v - goal).abs();
if d < best_d {
best_d = d;
best = j;
}
}
let target = ln.start + best;
drop(lay);
if shift {
if self.anchor.is_none() {
self.anchor = Some(self.cursor);
}
} else {
self.anchor = None;
}
self.cursor = target.min(self.char_count());
self.goal_x.set(Some(goal)); ctx.mark_dirty();
}
fn cur_line_bounds(&self) -> (usize, usize) {
let lay = self.layout.borrow();
if lay.lines.is_empty() {
return (0, self.char_count());
}
let li = self.cursor_line(&lay, self.cursor.min(self.char_count()));
let ln = &lay.lines[li];
(ln.start, ln.end)
}
fn insert_newline(&mut self, ctx: &mut EventCtx) {
self.delete_selection(ctx);
self.clamp_cursor();
let cursor = self.cursor;
self.text.update(|s| {
let byte = char_to_byte(s, cursor);
s.insert(byte, '\n');
});
self.cursor += 1;
self.anchor = None;
self.goal_x.set(None);
ctx.mark_dirty();
}
fn selected_text(&self) -> Option<String> {
let (s, e) = self.selection()?;
self.text.with(|t| {
let bs = char_to_byte(t, s);
let be = char_to_byte(t, e);
Some(t[bs..be].to_string())
})
}
fn paste(&mut self, ctx: &mut EventCtx, s: &str) {
self.delete_selection(ctx);
self.clamp_cursor();
let clean: String = if self.is_multiline() {
let normalized = s.replace("\r\n", "\n").replace('\r', "\n");
normalized
.chars()
.filter(|c| *c == '\n' || !c.is_control())
.collect()
} else {
s.chars().filter(|c| !c.is_control()).collect()
};
if clean.is_empty() {
return;
}
let cursor = self.cursor;
let added = clean.chars().count();
self.text.update(|t| {
let byte = char_to_byte(t, cursor);
t.insert_str(byte, &clean);
});
self.cursor += added;
self.anchor = None;
self.goal_x.set(None);
ctx.mark_dirty();
}
}
fn char_to_byte(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(b, _)| b)
.unwrap_or(s.len())
}
fn wrap_paragraph(
chars: &[char],
p: usize,
q: usize,
prefix: &[i32],
inner_w: i32,
wrap: bool,
) -> Vec<(usize, usize, bool)> {
if p == q {
return vec![(p, p, true)]; }
if !wrap || inner_w <= 0 {
return vec![(p, q, true)];
}
let mut out = Vec::new();
let mut ls = p;
while ls < q {
let base = prefix[ls - p];
let mut e = ls;
while e < q && prefix[e + 1 - p] - base <= inner_w {
e += 1;
}
if e == ls {
e = ls + 1;
}
let mut brk = e;
if e < q {
if let Some(sp) = (ls..e).rev().find(|&k| chars[k] == ' ') {
brk = sp + 1;
}
}
let hard = brk == q;
out.push((ls, brk, hard));
ls = brk;
}
out
}
#[derive(PartialEq, Eq, Clone, Copy)]
enum CharClass {
Word, Space, Other, }
fn classify(c: char) -> CharClass {
if c.is_alphanumeric() {
CharClass::Word
} else if c.is_whitespace() {
CharClass::Space
} else {
CharClass::Other
}
}
fn word_run(chars: &[char], idx: usize) -> (usize, usize) {
if chars.is_empty() {
return (0, 0);
}
let i = idx.min(chars.len() - 1);
let class = classify(chars[i]);
let mut s = i;
while s > 0 && classify(chars[s - 1]) == class {
s -= 1;
}
let mut e = i + 1;
while e < chars.len() && classify(chars[e]) == class {
e += 1;
}
(s, e)
}
impl Widget for TextInput {
fn measure(&self, _avail: Size, style: &Style, text: &mut dyn TextEngine) -> Size {
let lh = text
.measure("Ay", style.font_family.as_deref(), style.font_size, None)
.h
.max(style.font_size as i32);
let h = if self.is_multiline() {
lh * 5 + 2 * TEXT_PAD
} else {
(style.font_size as i32) + 16
};
Size::new(160, h)
}
fn paint(
&self,
bounds: Rect,
_content: Rect,
focused: bool,
enabled: bool,
canvas: &mut dyn Canvas,
style: &Style,
) {
let th = crate::theme::current();
let (pal, inp) = (&th.palette, &th.input);
let (x, y, w, h) = (
bounds.x as f32,
bounds.y as f32,
bounds.w as f32,
bounds.h as f32,
);
let corner = inp.corner(&th.metrics);
let bg = if enabled {
inp.bg(pal)
} else {
pal.surface_alt
};
let text_color = if enabled {
style.resolved_fg(&th)
} else {
pal.text_disabled
};
canvas.fill_round_rect(x, y, w, h, corner, &Paint::fill(bg));
let border = if focused {
inp.border_focus(pal)
} else {
inp.border(pal)
};
let t = crate::theme::current();
let bw = t.metrics.border_width.to_logical(canvas.dpi_scale());
canvas.stroke_round_rect(x, y, w, h, corner, bw, &Paint::fill(border));
let disp = self.display_string();
let is_empty = self.text.with(|t| t.is_empty());
let multiline = self.is_multiline();
let vpad = if multiline { TEXT_PAD } else { 0 };
let lead = self.lead_inset(style.font_size);
let inner = Rect::new(
bounds.x + TEXT_PAD + lead,
bounds.y + vpad,
bounds.w - 2 * TEXT_PAD - lead,
bounds.h - 2 * vpad,
);
let family = style.font_family.as_deref();
let fsize = style.font_size;
self.font_size_hint.set(fsize);
if let Some(g) = self.config.leading {
let mut buf = [0u8; 4];
let icon_rect = Rect::new(bounds.x + TEXT_PAD, bounds.y, lead, bounds.h);
canvas.draw_text(
g.encode_utf8(&mut buf),
icon_rect,
pal.placeholder,
Align::Center,
family,
fsize,
);
}
let wrap = self.config.wrap && multiline;
let cursor = self.cursor.min(disp.chars().count());
self.rebuild_layout(canvas, &disp, family, fsize, inner.w);
let lay = self.layout.borrow();
let line_h = lay.line_h.max(1);
let (cl, cx_in) = self.caret_line_x(&lay, cursor);
let mut sy = self.scroll_y.get();
if multiline {
let content_h = lay.lines.len() as i32 * line_h;
if self.follow_cursor.get() {
let caret_top = cl as i32 * line_h;
if caret_top - sy < 0 {
sy = caret_top;
}
if caret_top + line_h - sy > inner.h {
sy = caret_top + line_h - inner.h;
}
self.follow_cursor.set(false);
}
sy = sy.clamp(0, (content_h - inner.h).max(0));
} else {
sy = 0;
}
self.scroll_y.set(sy);
let mut sx = self.scroll_x.get();
if !wrap {
if cx_in - sx > inner.w {
sx = cx_in - inner.w;
}
if cx_in - sx < 0 {
sx = cx_in;
}
sx = sx.max(0);
} else {
sx = 0;
}
self.scroll_x.set(sx);
let first_line_y = if multiline {
inner.y - sy
} else {
inner.y + (inner.h - line_h) / 2
};
let base_x = inner.x - sx;
canvas.save();
canvas.clip_rect(inner);
if let Some((s, e)) = self.selection() {
for (i, ln) in lay.lines.iter().enumerate() {
let ly = first_line_y + i as i32 * line_h;
if ly + line_h < inner.y || ly > inner.y + inner.h {
continue;
}
let a = s.clamp(ln.start, ln.end);
let b = e.clamp(ln.start, ln.end);
let cont = e > ln.end && s <= ln.end; if b > a || cont {
let x1 = ln.x[a - ln.start];
let x2 = if cont {
ln.x.last().copied().unwrap_or(0) + SEL_EOL_EXTRA
} else {
ln.x[b - ln.start]
};
canvas.fill_rect(
(base_x + x1) as f32,
(ly + 2) as f32,
(x2 - x1) as f32,
(line_h - 4) as f32,
&Paint::fill(inp.selection(pal)),
);
}
}
}
if is_empty {
let pr = Rect::new(inner.x, first_line_y, inner.w, line_h);
canvas.draw_text(
&self.placeholder,
pr,
inp.placeholder(pal),
Align::Start,
family,
fsize,
);
} else {
let chars: Vec<char> = disp.chars().collect();
for (i, ln) in lay.lines.iter().enumerate() {
let ly = first_line_y + i as i32 * line_h;
if ly + line_h < inner.y || ly > inner.y + inner.h {
continue;
}
if ln.end > ln.start {
let s: String = chars[ln.start..ln.end].iter().collect();
let tr = Rect::new(base_x, ly, NO_WRAP_W, line_h);
canvas.draw_text(&s, tr, text_color, Align::Start, family, fsize);
}
}
}
let ly = first_line_y + cl as i32 * line_h;
let cxx = base_x + cx_in;
self.caret_local
.set(Some((cxx - bounds.x, ly - bounds.y, line_h)));
if focused {
canvas.draw_line(
cxx as f32,
(ly + 2) as f32,
cxx as f32,
(ly + line_h - 2) as f32,
1.0,
&Paint::fill(inp.cursor(pal)),
);
}
canvas.restore();
if multiline {
let content_h = lay.lines.len() as i32 * line_h;
self.scrollbar.paint(canvas, bounds, sy, content_h, inner.h);
}
}
fn on_event(&mut self, ctx: &mut EventCtx, ev: &Event) -> bool {
match ev {
Event::Pointer(p) => match p.kind {
PointerKind::Down => {
ctx.request_focus();
if self.is_multiline() && p.button != MouseButton::Right {
let b = ctx.bounds();
let view_h = b.h - 2 * TEXT_PAD;
let (content_h, _) = {
let lay = self.layout.borrow();
let lh = lay.line_h.max(1);
(lay.lines.len() as i32 * lh, lh)
};
if self.scrollbar.on_down(
p.pos,
b,
self.scroll_y.get(),
content_h,
view_h,
ctx,
) {
ctx.mark_dirty();
return true;
}
}
if p.button == MouseButton::Right {
let idx = self.pos_to_index(ctx, p.pos.x, p.pos.y);
let in_sel = self.selection().is_some_and(|(s, e)| idx >= s && idx < e);
if !in_sel {
self.cursor = idx;
self.anchor = None;
}
let items = self.context_menu_items();
ctx.show_context_menu(p.pos, items);
return true;
}
match p.click_count {
2 => {
let idx = self.pos_to_index(ctx, p.pos.x, p.pos.y);
self.select_word(idx);
self.dragging = false;
self.follow_cursor.set(true);
ctx.mark_dirty();
return true;
}
n if n >= 3 => {
let idx = self.pos_to_index(ctx, p.pos.x, p.pos.y);
self.select_para(idx);
self.dragging = false;
self.follow_cursor.set(true);
ctx.mark_dirty();
return true;
}
_ => {}
}
let idx = self.pos_to_index(ctx, p.pos.x, p.pos.y);
self.cursor = idx;
self.anchor = Some(idx);
self.dragging = true;
self.goal_x.set(None);
self.follow_cursor.set(true);
ctx.capture();
ctx.mark_dirty();
true
}
PointerKind::Move => {
if self.scrollbar.dragging {
if let Some(new_sy) = self.scrollbar.on_move(p.pos) {
self.scroll_y.set(new_sy);
ctx.mark_dirty();
}
return true;
}
if self.dragging {
if self.is_multiline() {
let b = ctx.bounds();
let inner_h = b.h - 2 * TEXT_PAD;
let (content_h, line_h) = {
let lay = self.layout.borrow();
let lh = lay.line_h.max(1);
(lay.lines.len() as i32 * lh, lh)
};
let max_scroll = (content_h - inner_h).max(0);
if max_scroll > 0 {
let sy = self.scroll_y.get();
let top_edge = b.y + TEXT_PAD;
let bot_edge = b.y + b.h - TEXT_PAD;
if p.pos.y < top_edge && sy > 0 {
let step = ((top_edge - p.pos.y) / 5).clamp(1, line_h);
self.scroll_y.set((sy - step).max(0));
} else if p.pos.y > bot_edge && sy < max_scroll {
let step = ((p.pos.y - bot_edge) / 5).clamp(1, line_h);
self.scroll_y.set((sy + step).min(max_scroll));
}
}
}
self.cursor = self.pos_to_index(ctx, p.pos.x, p.pos.y);
ctx.mark_dirty();
return true;
}
if self.is_multiline() {
let b = ctx.bounds();
let in_sb = p.pos.x >= b.right() - VScrollbar::HIT_W;
if in_sb != self.hover_in_scrollbar.get() {
self.hover_in_scrollbar.set(in_sb);
}
}
false
}
PointerKind::Leave => {
if self.hover_in_scrollbar.get() {
self.hover_in_scrollbar.set(false);
}
false
}
PointerKind::Up => {
if self.scrollbar.on_up(ctx) {
ctx.mark_dirty();
return true;
}
self.dragging = false;
ctx.release_capture();
if self.anchor == Some(self.cursor) {
self.anchor = None;
}
ctx.mark_dirty();
true
}
PointerKind::Wheel(delta) if self.is_multiline() => {
let b = ctx.bounds();
let inner_h = b.h - 2 * TEXT_PAD;
let (content_h, line_h) = {
let lay = self.layout.borrow();
let lh = lay.line_h.max(1);
(lay.lines.len() as i32 * lh, lh)
};
if content_h <= inner_h {
return false; }
let max_scroll = (content_h - inner_h).max(0);
let sy_old = self.scroll_y.get();
let step = (3 * line_h).max(48);
let dy = -delta * step / 120;
let at_boundary = (dy < 0 && sy_old == 0) || (dy > 0 && sy_old >= max_scroll);
if at_boundary {
return false;
}
self.scroll_y.set((sy_old + dy).clamp(0, max_scroll));
ctx.mark_dirty();
true
}
_ => false,
},
Event::Key(k) if k.pressed => {
self.follow_cursor.set(true);
let len = self.char_count();
match k.key {
Key::Char(c) if !k.ctrl => {
self.type_char(ctx, c);
true
}
Key::Backspace => {
if !self.delete_selection(ctx) {
self.backspace(ctx);
}
true
}
Key::Delete => {
if !self.delete_selection(ctx) {
self.delete_forward(ctx);
}
true
}
Key::Enter if self.is_multiline() => {
self.insert_newline(ctx);
true
}
Key::Up if self.is_multiline() => {
self.move_vertical(ctx, false, k.shift);
true
}
Key::Down if self.is_multiline() => {
self.move_vertical(ctx, true, k.shift);
true
}
Key::Left => {
if !k.shift {
if let Some((s, _)) = self.selection() {
self.cursor = s;
self.anchor = None;
ctx.mark_dirty();
return true;
}
}
self.move_to(ctx, self.cursor.saturating_sub(1), k.shift);
true
}
Key::Right => {
if !k.shift {
if let Some((_, e)) = self.selection() {
self.cursor = e;
self.anchor = None;
ctx.mark_dirty();
return true;
}
}
self.move_to(ctx, (self.cursor + 1).min(len), k.shift);
true
}
Key::Home => {
let target = if self.is_multiline() {
self.cur_line_bounds().0
} else {
0
};
self.move_to(ctx, target, k.shift);
true
}
Key::End => {
let target = if self.is_multiline() {
self.cur_line_bounds().1
} else {
len
};
self.move_to(ctx, target, k.shift);
true
}
Key::Other(0x41) if k.ctrl => {
self.select_all();
ctx.mark_dirty();
true
}
Key::Other(0x43) if k.ctrl => {
if !self.config.password {
if let Some(sel) = self.selected_text() {
ctx.clipboard_set(&sel);
}
}
true
}
Key::Other(0x58) if k.ctrl => {
if !self.config.password {
if let Some(sel) = self.selected_text() {
ctx.clipboard_set(&sel);
self.delete_selection(ctx);
}
}
true
}
Key::Other(0x56) if k.ctrl => {
if let Some(s) = ctx.clipboard_get() {
self.paste(ctx, &s);
}
true
}
_ => false,
}
}
_ => false,
}
}
fn focusable(&self) -> bool {
true
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
fn ime_caret(&self) -> Option<(i32, i32, i32)> {
self.caret_local.get()
}
fn wants_right_click(&self) -> bool {
true }
fn cursor(&self) -> CursorShape {
if self.scrollbar.dragging || self.hover_in_scrollbar.get() {
CursorShape::Arrow
} else {
CursorShape::Text
}
}
}
#[cfg(test)]
mod tests {
use super::{word_run, wrap_paragraph, TextInput, TextLayout, VisLine};
use crate::signal::signal;
fn run(s: &str, idx: usize) -> (usize, usize) {
let chars: Vec<char> = s.chars().collect();
word_run(&chars, idx)
}
fn prefix10(len: usize) -> Vec<i32> {
(0..=len).map(|i| i as i32 * 10).collect()
}
#[test]
fn wrap_paragraph_char_wrap() {
let chars: Vec<char> = "abcdef".chars().collect();
let pre = prefix10(6);
let lines = wrap_paragraph(&chars, 0, 6, &pre, 25, true);
assert_eq!(lines, vec![(0, 2, false), (2, 4, false), (4, 6, true)]);
}
#[test]
fn wrap_paragraph_word_break() {
let chars: Vec<char> = "ab cd ef".chars().collect();
let pre = prefix10(8);
let lines = wrap_paragraph(&chars, 0, 8, &pre, 45, true);
assert_eq!(lines, vec![(0, 3, false), (3, 6, false), (6, 8, true)]);
}
#[test]
fn wrap_paragraph_nowrap_and_empty() {
let chars: Vec<char> = "abc".chars().collect();
let pre = prefix10(3);
assert_eq!(
wrap_paragraph(&chars, 0, 3, &pre, 10, false),
vec![(0, 3, true)]
);
assert_eq!(
wrap_paragraph(&chars, 3, 3, &[0], 50, true),
vec![(3, 3, true)]
);
}
fn dummy_input() -> TextInput {
TextInput::new(signal(String::new()), String::new())
}
#[test]
fn layout_cache_hit_then_invalidates_on_change() {
use crate::render::SkiaCanvas;
use tiny_skia::Pixmap;
let ti = dummy_input(); let mut pm = Pixmap::new(200, 30).unwrap();
let mut c = SkiaCanvas::new(&mut pm); ti.rebuild_layout(&mut c, "abc", None, 14.0, 200);
assert_eq!(ti.layout.borrow().lines.len(), 1);
assert_eq!(ti.layout.borrow().lines[0].end, 3);
ti.rebuild_layout(&mut c, "abc", None, 14.0, 200);
assert_eq!(ti.layout.borrow().lines[0].end, 3, "缓存命中应沿用旧行");
ti.rebuild_layout(&mut c, "abcdefghij", None, 14.0, 200);
assert_eq!(ti.layout.borrow().lines[0].end, 10, "文本变化后应重建");
}
#[test]
fn cursor_line_soft_break_affinity() {
let ti = dummy_input();
let lay = TextLayout {
lines: vec![
VisLine {
start: 0,
end: 3,
x: vec![0, 10, 20, 30],
hard: false,
},
VisLine {
start: 3,
end: 6,
x: vec![0, 10, 20, 30],
hard: true,
},
],
line_h: 14,
key: None,
};
assert_eq!(ti.cursor_line(&lay, 0), 0);
assert_eq!(ti.cursor_line(&lay, 2), 0);
assert_eq!(ti.cursor_line(&lay, 3), 1);
assert_eq!(ti.cursor_line(&lay, 6), 1);
}
#[test]
fn cursor_line_hard_break_stays() {
let ti = dummy_input();
let lay = TextLayout {
lines: vec![
VisLine {
start: 0,
end: 1,
x: vec![0, 10],
hard: true,
},
VisLine {
start: 2,
end: 3,
x: vec![0, 10],
hard: true,
},
],
line_h: 14,
key: None,
};
assert_eq!(ti.cursor_line(&lay, 1), 0);
assert_eq!(ti.cursor_line(&lay, 2), 1);
}
#[test]
fn word_run_selects_alnum_word() {
assert_eq!(run("hello world", 0), (0, 5));
assert_eq!(run("hello world", 3), (0, 5));
assert_eq!(run("hello world", 5), (5, 6));
assert_eq!(run("hello world", 8), (6, 11));
}
#[test]
fn word_run_handles_punct_and_cjk() {
assert_eq!(run("a!!b", 1), (1, 3));
assert_eq!(run("你好world", 1), (0, 7));
assert_eq!(run("你好world", 3), (0, 7));
assert_eq!(run("你好 world", 1), (0, 2));
assert_eq!(run("你好 world", 4), (3, 8));
}
#[test]
fn word_run_empty_and_end() {
assert_eq!(run("", 0), (0, 0));
assert_eq!(run("ab", 5), (0, 2));
}
}
#[cfg(test)]
mod anim_tests {
use super::Switch;
use crate::core::Widget;
use crate::geometry::Rect;
use crate::render::SkiaCanvas;
use crate::signal::signal;
use crate::style::Style;
use tiny_skia::Pixmap;
fn paint_at(sw: &Switch, clock: u64) {
crate::anim::set_clock_ms(clock);
let mut pm = Pixmap::new(60, 30).unwrap();
let mut c = SkiaCanvas::new(&mut pm);
sw.paint(
Rect::new(0, 0, 44, 24),
Rect::new(0, 0, 44, 24),
false,
true,
&mut c,
&Style::default(),
);
}
#[test]
fn pos_initializes_to_state_no_first_frame_anim() {
assert_eq!(Switch::new(signal(true)).pos.get().value(), 1.0);
assert_eq!(Switch::new(signal(false)).pos.get().value(), 0.0);
}
#[test]
fn retargets_and_settles_when_animated() {
crate::anim::set_enabled(true);
let state = signal(false);
let sw = Switch::new(state);
paint_at(&sw, 0);
state.set(true); paint_at(&sw, 0); assert_eq!(sw.pos.get().target(), 1.0, "状态变 on 后 pos 目标应为 1");
assert!(sw.pos.get().is_active(), "动画开启时应在过渡中");
paint_at(&sw, 5000); assert_eq!(sw.pos.get().value(), 1.0);
assert!(!sw.pos.get().is_active());
}
#[test]
fn snaps_when_animation_disabled() {
crate::anim::set_enabled(false);
let state = signal(false);
let sw = Switch::new(state);
state.set(true);
paint_at(&sw, 0);
assert_eq!(sw.pos.get().value(), 1.0, "关闭动画应瞬时到 on");
assert!(!sw.pos.get().is_active());
crate::anim::set_enabled(true);
}
}