use std::cell::{Cell, RefCell};
use std::collections::VecDeque;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use teksilo_canvas::Point;
use teksilo_core::signal::Signal;
use teksilo_core::widget::EventContext;
use teksilo_core::widget_id::WidgetId;
use teksilo_text::text_document::{DocumentEvent, Subscription, TextCursor, TextDocument};
use teksilo_text::{RichTextEngine, WrapMode};
use super::{AtRevealPolicy, EchoMode};
use crate::common::editor_runtime::{CaretBlink, Debounce};
use crate::rich_text::image_cache::ImageCache;
pub(crate) type CommandFactory = Box<dyn Fn(&mut EventContext)>;
pub(crate) type CharFilter = Rc<dyn Fn(char) -> bool>;
pub(crate) type SharedState = Rc<RefCell<TextInputState>>;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum DragState {
Idle,
Selecting,
}
pub(crate) struct TextInputState {
pub document: TextDocument,
pub engine: RichTextEngine,
pub cursor: TextCursor,
pub text_signal: Signal<String>,
pub cursor_position: Signal<usize>,
pub cursor_anchor: Signal<usize>,
pub has_selection: Signal<bool>,
pub caret_visible: Signal<bool>,
pub can_undo: Signal<bool>,
pub can_redo: Signal<bool>,
pub frame_request: Option<Rc<Cell<bool>>>,
pub frame_wake_at: Option<Rc<Cell<Option<std::time::Instant>>>>,
pub blink: CaretBlink,
pub scroll_x: f32,
pub viewport_width: f32,
pub viewport_origin: Point,
pub pending_chars: String,
pub pending_text_changed: bool,
pub deferred_text_update: Option<String>,
pub debounce: Debounce,
pub pending_undo_redo: Option<(bool, bool)>,
pub event_queue: Arc<Mutex<VecDeque<DocumentEvent>>>,
pub _event_subscription: Subscription,
pub has_focus: bool,
pub focus_signal: Signal<bool>,
pub window_active: bool,
pub selection_tint: [f32; 4],
pub drag_state: DragState,
pub needs_full_layout: bool,
pub content_dirty: bool,
pub last_text_scale: f32,
pub image_cache: ImageCache,
pub max_length: Option<usize>,
pub read_only: bool,
pub on_submit: Option<Rc<CommandFactory>>,
pub on_blur: Option<Rc<CommandFactory>>,
pub char_filter: Option<CharFilter>,
pub placeholder: String,
pub suffix: String,
pub suffix_engine: Option<RichTextEngine>,
pub suffix_width: f32,
pub secure: bool,
pub echo_mode: EchoMode,
pub echo_char: char,
pub revealed: Option<Signal<bool>>,
pub at_reveal_policy: AtRevealPolicy,
pub allow_copy: bool,
pub empty_doc: TextDocument,
pub field_widget_id: Option<WidgetId>,
pub ime_preedit: Option<String>,
pub ime_preedit_range: Option<std::ops::Range<usize>>,
pub last_ime_area: Option<teksilo_canvas::Rect>,
}
pub(crate) struct TextInputConfig {
pub initial_text: String,
pub max_length: Option<usize>,
pub read_only: bool,
pub on_submit: Option<Rc<CommandFactory>>,
pub on_blur: Option<Rc<CommandFactory>>,
pub char_filter: Option<CharFilter>,
pub placeholder: String,
pub suffix: String,
pub secure: bool,
pub echo_mode: EchoMode,
pub echo_char: char,
pub revealed: Option<Signal<bool>>,
pub at_reveal_policy: AtRevealPolicy,
pub allow_copy: bool,
pub focus_signal: Signal<bool>,
}
impl TextInputState {
pub fn new(config: TextInputConfig) -> SharedState {
let TextInputConfig {
initial_text,
max_length,
read_only,
on_submit,
on_blur,
char_filter,
placeholder,
suffix,
secure,
echo_mode,
echo_char,
revealed,
at_reveal_policy,
allow_copy,
focus_signal,
} = config;
let document = TextDocument::new();
if !initial_text.is_empty() {
let _ = document.set_plain_text(&initial_text);
}
let cursor = document.cursor();
let mut engine = RichTextEngine::private_default();
engine.set_wrap_mode(WrapMode::None);
let event_queue = Arc::new(Mutex::new(VecDeque::<DocumentEvent>::new()));
let subscription = {
let queue = event_queue.clone();
document.on_change(move |event| {
if let Ok(mut q) = queue.lock() {
q.push_back(event);
}
})
};
let initial_can_undo = document.can_undo();
let initial_can_redo = document.can_redo();
Rc::new(RefCell::new(Self {
document,
engine,
cursor,
text_signal: Signal::new(initial_text.clone()),
cursor_position: Signal::new(0),
cursor_anchor: Signal::new(0),
has_selection: Signal::new(false),
caret_visible: Signal::new(true),
can_undo: Signal::new(initial_can_undo),
can_redo: Signal::new(initial_can_redo),
frame_request: None,
frame_wake_at: None,
blink: CaretBlink::new(),
scroll_x: 0.0,
viewport_width: 0.0,
viewport_origin: Point::ZERO,
pending_chars: String::new(),
pending_text_changed: false,
deferred_text_update: None,
debounce: Debounce::new(), pending_undo_redo: None,
event_queue,
_event_subscription: subscription,
has_focus: false,
focus_signal,
window_active: true,
selection_tint: [0.0; 4],
drag_state: DragState::Idle,
needs_full_layout: true,
content_dirty: true,
last_text_scale: 1.0,
image_cache: ImageCache::new(),
max_length,
read_only,
on_submit,
on_blur,
char_filter,
placeholder,
suffix,
secure,
echo_mode,
echo_char,
revealed,
at_reveal_policy,
allow_copy,
empty_doc: TextDocument::new(),
suffix_engine: None,
suffix_width: 0.0,
field_widget_id: None,
ime_preedit: None,
ime_preedit_range: None,
last_ime_area: None,
}))
}
pub fn char_filter_admits(&self, c: char) -> bool {
self.char_filter.as_ref().is_none_or(|f| f(c))
}
pub fn reveal_active(&self) -> bool {
if !self.secure {
return true;
}
let toggled = self.revealed.as_ref().is_some_and(|s| s.get());
toggled || (self.echo_mode == EchoMode::RevealWhileTyping && self.has_focus)
}
pub fn should_mask(&self) -> bool {
self.secure && !self.reveal_active()
}
pub fn copy_allowed(&self) -> bool {
!self.secure || self.allow_copy || self.reveal_active()
}
pub fn apply_font_scale(&mut self, scale: f32) {
if (self.last_text_scale - scale).abs() <= f32::EPSILON {
return;
}
self.last_text_scale = scale;
self.engine.set_font_scale(scale);
self.needs_full_layout = true;
if !self.suffix.is_empty()
&& let Some(engine) = self.suffix_engine.as_mut()
{
engine.set_font_scale(scale);
let doc = TextDocument::new();
let _ = doc.set_plain_text(&self.suffix);
let flow = doc.snapshot_flow();
engine.layout_full(&flow);
self.suffix_width = engine.max_content_width();
}
}
pub fn sync_viewport(&mut self, bounds: teksilo_canvas::Rect) -> bool {
self.viewport_origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
let changed = (self.viewport_width - bounds.width).abs() > 0.5;
if changed {
self.viewport_width = bounds.width;
self.needs_full_layout = true;
}
changed
}
pub fn layout_full_masked(&mut self) {
let masked = self.should_mask();
let echo = if masked && self.echo_mode != EchoMode::NoEcho {
Some(self.echo_char)
} else {
None
};
if self.engine.echo_char() != echo {
self.engine.set_echo_char(echo);
}
if masked && self.echo_mode == EchoMode::NoEcho {
let flow = self.empty_doc.snapshot_flow();
self.engine.layout_full(&flow);
} else {
let flow = self.document.snapshot_flow();
self.engine.layout_full(&flow);
}
}
pub fn drain_events(&mut self) -> bool {
let drained: Vec<DocumentEvent> = {
let mut q = self.event_queue.lock().expect("event queue mutex poisoned");
q.drain(..).collect()
};
let mut had_events = false;
for event in drained {
had_events = true;
match event {
DocumentEvent::ContentsChanged { .. }
| DocumentEvent::DocumentReset
| DocumentEvent::FlowElementsInserted { .. }
| DocumentEvent::FlowElementsRemoved { .. }
| DocumentEvent::BlockCountChanged(_) => {
self.pending_text_changed = true;
self.needs_full_layout = true;
}
DocumentEvent::FormatChanged { .. }
| DocumentEvent::HighlightPaintChanged { .. } => {
self.needs_full_layout = true;
}
DocumentEvent::UndoRedoChanged { can_undo, can_redo } => {
self.pending_undo_redo = Some((can_undo, can_redo));
}
DocumentEvent::TextInserted { .. }
| DocumentEvent::ModificationChanged(_)
| DocumentEvent::LongOperationProgress { .. }
| DocumentEvent::LongOperationFinished { .. } => {}
}
}
if had_events {
self.content_dirty = true;
}
had_events
}
}
pub(crate) fn sync_cursor_signals(state: &SharedState) {
let st = state.borrow();
let pos = st.cursor.position();
let anchor = st.cursor.anchor();
let has_sel = st.cursor.has_selection();
if st.cursor_position.get() != pos {
st.cursor_position.set(pos);
}
if st.cursor_anchor.get() != anchor {
st.cursor_anchor.set(anchor);
}
if st.has_selection.get() != has_sel {
st.has_selection.set(has_sel);
}
drop(st);
let mut st = state.borrow_mut();
st.blink.restart();
st.caret_visible.set(true);
}
#[cfg(test)]
mod secure_tests {
use super::*;
fn cfg(
secure: bool,
echo_mode: EchoMode,
revealed: Option<Signal<bool>>,
allow_copy: bool,
) -> TextInputConfig {
TextInputConfig {
initial_text: "abc".to_string(),
max_length: None,
read_only: false,
on_submit: None,
on_blur: None,
char_filter: None,
placeholder: String::new(),
suffix: String::new(),
secure,
echo_mode,
echo_char: '\u{2022}',
revealed,
at_reveal_policy: AtRevealPolicy::SwapRole,
allow_copy,
focus_signal: Signal::new(false),
}
}
#[test]
fn plain_field_never_masks_and_allows_copy() {
let st = TextInputState::new(cfg(false, EchoMode::Masked, None, true));
let st = st.borrow();
assert!(!st.should_mask());
assert!(st.reveal_active());
assert!(st.copy_allowed());
}
#[test]
fn masked_secure_field_masks_and_blocks_copy() {
let st = TextInputState::new(cfg(true, EchoMode::Masked, None, false));
let st = st.borrow();
assert!(st.should_mask());
assert!(!st.reveal_active());
assert!(!st.copy_allowed(), "masked secure field must block copy");
}
#[test]
fn revealed_secure_field_unmasks_and_allows_copy() {
let revealed = Signal::new(true);
let st = TextInputState::new(cfg(true, EchoMode::Masked, Some(revealed), false));
let st = st.borrow();
assert!(!st.should_mask());
assert!(st.copy_allowed(), "copy allowed once revealed");
}
#[test]
fn allow_copy_opt_in_permits_copy_while_masked() {
let st = TextInputState::new(cfg(true, EchoMode::Masked, None, true));
let st = st.borrow();
assert!(st.should_mask(), "still visually masked");
assert!(st.copy_allowed(), "developer opted into copy");
}
#[test]
fn reveal_while_typing_unmasks_only_when_focused() {
let st = TextInputState::new(cfg(true, EchoMode::RevealWhileTyping, None, false));
assert!(st.borrow().should_mask(), "masked when unfocused");
{
let mut st = st.borrow_mut();
st.has_focus = true;
st.focus_signal.set(true);
}
let s = st.borrow();
assert!(!s.should_mask(), "revealed while focused");
assert!(s.copy_allowed(), "copy allowed while revealed by typing");
}
}