Skip to main content

fission_core/
env.rs

1use crate::{
2    action::GlobalState, motion::MotionStateMap, state::LocalStateStore, ui::VideoAudioOptions,
3};
4use fission_i18n::{I18nRegistry, Locale};
5use fission_ir::op::RichTextAnnotation;
6use fission_ir::semantics::MouseCursor;
7use fission_ir::WidgetId;
8use fission_layout::{LayoutPoint, LayoutSize};
9use fission_text_engine::{EditTransaction, TextBuffer, TextEdit};
10use fission_theme::{DesignMode, Theme};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::sync::Arc;
14
15#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
16pub struct WindowInsets {
17    pub top: f32,
18    pub bottom: f32,
19    pub left: f32,
20    pub right: f32,
21}
22
23#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
24pub enum WindowTitle {
25    Plain(String),
26    // Rich(WindowTitleContent),
27}
28
29impl Default for WindowTitle {
30    fn default() -> Self {
31        Self::Plain("Fission".into())
32    }
33}
34
35impl WindowTitle {
36    pub fn plain(title: impl Into<String>) -> Self {
37        Self::Plain(title.into())
38    }
39
40    pub fn plain_text(&self) -> &str {
41        match self {
42            Self::Plain(title) => title,
43        }
44    }
45}
46
47#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
48pub struct WindowEnv {
49    pub title: WindowTitle,
50}
51
52/// Browser-compatible route location supplied by the host shell.
53///
54/// Only `pathname` is required. The remaining fields mirror `window.location`
55/// so desktop, web, and embedded hosts can pass richer navigation context
56/// without coupling applications to a specific shell implementation.
57#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
58pub struct RouteLocation {
59    pub pathname: String,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub host: Option<String>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub hash: Option<String>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub hostname: Option<String>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub href: Option<String>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub origin: Option<String>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub port: Option<String>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub protocol: Option<String>,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub search: Option<String>,
76}
77
78impl Default for RouteLocation {
79    fn default() -> Self {
80        Self::new("/")
81    }
82}
83
84impl RouteLocation {
85    pub fn new(pathname: impl Into<String>) -> Self {
86        Self {
87            pathname: pathname.into(),
88            host: None,
89            hash: None,
90            hostname: None,
91            href: None,
92            origin: None,
93            port: None,
94            protocol: None,
95            search: None,
96        }
97    }
98
99    /// Parses a logical route into pathname, query, and fragment fields.
100    ///
101    /// Both `/projects/42` and `#/projects/42` produce the same pathname. This
102    /// parser is intentionally origin-free; browser shells populate the host,
103    /// origin, protocol, and full href from `window.location`.
104    pub fn from_route(route: impl AsRef<str>) -> Self {
105        let route = route.as_ref().trim();
106        let logical = route.strip_prefix("#/").map_or(route, |path| {
107            if path.is_empty() {
108                "/"
109            } else {
110                // Preserve the leading slash removed as part of `#/`.
111                route.strip_prefix('#').unwrap_or(route)
112            }
113        });
114        let (path_and_query, fragment) = logical.split_once('#').unwrap_or((logical, ""));
115        let (path, query) = path_and_query
116            .split_once('?')
117            .unwrap_or((path_and_query, ""));
118        let pathname = if path.is_empty() {
119            "/".to_string()
120        } else if path.starts_with('/') {
121            path.to_string()
122        } else {
123            format!("/{path}")
124        };
125        Self {
126            pathname,
127            hash: (!fragment.is_empty()).then(|| format!("#{fragment}")),
128            search: (!query.is_empty()).then(|| format!("?{query}")),
129            ..Self::default()
130        }
131    }
132}
133
134// Static environment data (Theme, I18n)
135#[derive(Clone)]
136pub struct Env {
137    pub theme: Theme,
138    /// Current light/dark appearance reported by the host platform.
139    ///
140    /// Applications that offer a "System" preference can select their generated
141    /// design-system theme from this value during environment synchronization.
142    pub system_theme_mode: DesignMode,
143    pub i18n: I18nRegistry,
144    pub locale: Locale,
145    pub window: WindowEnv,
146    pub current_route: RouteLocation,
147    pub window_insets: WindowInsets,
148    pub viewport_size: LayoutSize,
149    /// Host accessibility text scaling applied when a text widget does not
150    /// provide an explicit scaler.
151    pub text_scaler: crate::ui::TextScaler,
152    pub measurer: Option<Arc<dyn fission_layout::TextMeasurer>>,
153}
154
155impl Default for Env {
156    fn default() -> Self {
157        Self {
158            theme: Theme::default(),
159            system_theme_mode: DesignMode::Light,
160            i18n: I18nRegistry::new(),
161            locale: Locale::default(),
162            window: WindowEnv::default(),
163            current_route: RouteLocation::default(),
164            window_insets: WindowInsets::default(),
165            viewport_size: LayoutSize::default(),
166            text_scaler: crate::ui::TextScaler::default(),
167            measurer: None,
168        }
169    }
170}
171
172impl std::fmt::Debug for Env {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        f.debug_struct("Env")
175            .field("theme", &self.theme)
176            .field("system_theme_mode", &self.system_theme_mode)
177            .field("locale", &self.locale)
178            .field("window", &self.window)
179            .field("current_route", &self.current_route)
180            .field("window_insets", &self.window_insets)
181            .field("viewport_size", &self.viewport_size)
182            .field("text_scaler", &self.text_scaler)
183            .finish()
184    }
185}
186
187impl Env {
188    pub fn new(measurer: Arc<dyn fission_layout::TextMeasurer>) -> Self {
189        Self {
190            theme: Theme::default(),
191            system_theme_mode: DesignMode::Light,
192            i18n: I18nRegistry::new(),
193            locale: Locale::default(),
194            window: WindowEnv::default(),
195            current_route: RouteLocation::default(),
196            window_insets: WindowInsets::default(),
197            viewport_size: LayoutSize::default(),
198            text_scaler: crate::ui::TextScaler::default(),
199            measurer: Some(measurer),
200        }
201    }
202}
203
204pub trait Clipboard: Send + Sync {
205    fn get_text(&self) -> Option<String>;
206    fn set_text(&self, text: &str);
207}
208
209pub trait ImeHandler: Send + Sync {
210    fn set_ime_allowed(&self, allowed: bool);
211    fn set_ime_cursor_area(&self, rect: fission_layout::LayoutRect);
212    /// Synchronizes the complete authoritative value for the focused editing
213    /// session. Platform adapters must not retain an independent text value.
214    fn set_editing_value(&self, _value: &crate::TextEditingValue) {}
215}
216
217// Runtime state managed by framework (Interaction)
218#[derive(Clone, Debug, Default)]
219pub struct RuntimeState {
220    pub local_widget_state: LocalStateStore,
221    pub scroll: ScrollStateMap,
222    pub viewport: crate::input::viewport::ViewportStateMap,
223    pub video: VideoStateMap,
224    pub web: WebStateMap,
225    pub motion: MotionStateMap,
226    pub interaction: InteractionStateMap,
227    pub text_edit: TextEditStateMap,
228    pub selectable_text: SelectableTextStateMap,
229    pub context_menu: ContextMenuState,
230    pub clipboard: String,
231    pub caret_visible: HashMap<WidgetId, bool>,
232    pub gesture: GestureState,
233    pub hero: HeroState,
234}
235
236#[derive(Clone, Debug, Default)]
237pub struct HeroState {
238    // tag -> (Last Known WidgetId, Last Known Rect)
239    pub positions: HashMap<String, (WidgetId, fission_layout::LayoutRect)>,
240}
241
242#[derive(Clone, Debug, Default)]
243pub struct GestureState {
244    pub start_point: Option<LayoutPoint>,
245    pub last_point: Option<LayoutPoint>,
246    pub is_panning: bool,
247    pub target_node: Option<WidgetId>,
248    pub dragging_payload: Option<Vec<u8>>,
249    pub pressed_button: Option<crate::event::PointerButton>,
250    pub pointer_kind: crate::event::PointerKind,
251    /// Modifier bitmask for the active pointer sequence.
252    pub modifiers: u8,
253    pub scrollbar_drag: Option<crate::scrollbar::ScrollbarDragState>,
254    /// Runtime drag state used by widgets to render previews and hovered
255    /// drop-target feedback during the current frame.
256    pub drag_session: Option<DragSessionState>,
257}
258
259/// Payload currently carried by a drag session.
260#[derive(Clone, Debug, PartialEq, Eq)]
261pub enum DragSessionPayload {
262    /// Opaque bytes from an in-app drag source.
263    Internal(Vec<u8>),
264    /// Files supplied by the host platform during an external drag.
265    ExternalFiles(Vec<String>),
266}
267
268impl DragSessionPayload {
269    /// Human-readable payload family used by demos and diagnostics.
270    pub fn kind(&self) -> &'static str {
271        match self {
272            Self::Internal(_) => "internal",
273            Self::ExternalFiles(_) => "files",
274        }
275    }
276}
277
278/// Runtime-only state for a drag gesture currently in progress.
279#[derive(Clone, Debug, PartialEq)]
280pub struct DragSessionState {
281    /// Semantics node that started the drag, when this is an internal drag.
282    pub source_node: Option<WidgetId>,
283    /// Stable source identifier, if supplied by the drag source widget.
284    pub source_identifier: Option<String>,
285    /// Payload carried by the drag.
286    pub payload: DragSessionPayload,
287    /// Pointer position in layout coordinates.
288    pub point: LayoutPoint,
289    /// Target currently under the pointer that advertises a drop action.
290    pub target_node: Option<WidgetId>,
291    /// Stable target identifier, if supplied by the drop target widget.
292    pub target_identifier: Option<String>,
293}
294
295#[derive(Clone, Debug, Default)]
296pub struct ScrollStateMap {
297    pub offsets: HashMap<WidgetId, f32>,
298}
299
300impl ScrollStateMap {
301    pub fn get_offset(&self, id: WidgetId) -> f32 {
302        *self.offsets.get(&id).unwrap_or(&0.0)
303    }
304
305    pub fn set_offset(&mut self, id: WidgetId, offset: f32) {
306        self.offsets.insert(id, offset);
307    }
308
309    pub fn retain_active(&mut self, active: &std::collections::HashSet<WidgetId>) {
310        self.offsets.retain(|id, _| active.contains(id));
311    }
312}
313
314#[derive(Clone, Debug, Default)]
315pub struct ContextMenuState {
316    pub owner: Option<WidgetId>,
317    pub anchor: Option<LayoutPoint>,
318}
319
320impl ContextMenuState {
321    pub fn open(&mut self, owner: WidgetId, anchor: LayoutPoint) {
322        self.owner = Some(owner);
323        self.anchor = Some(anchor);
324    }
325
326    pub fn close(&mut self) {
327        self.owner = None;
328        self.anchor = None;
329    }
330}
331
332#[derive(Clone, Debug, Default)]
333pub struct SelectableTextStateMap {
334    pub states: HashMap<WidgetId, SelectableTextState>,
335    pub(crate) regions: HashMap<WidgetId, SelectionRegionState>,
336}
337
338impl SelectableTextStateMap {
339    pub fn get(&self, id: WidgetId) -> Option<&SelectableTextState> {
340        self.states.get(&id)
341    }
342
343    pub fn get_mut_or_default(&mut self, id: WidgetId) -> &mut SelectableTextState {
344        self.states.entry(id).or_default()
345    }
346
347    pub fn selection_range(&self, id: WidgetId) -> Option<(usize, usize)> {
348        self.states
349            .get(&id)
350            .and_then(SelectableTextState::selection_range)
351    }
352
353    pub fn region_selection(&self, id: WidgetId) -> Option<crate::selection::TextRegionSelection> {
354        self.regions.get(&id).and_then(|state| state.selection)
355    }
356
357    pub(crate) fn region(&self, id: WidgetId) -> Option<&SelectionRegionState> {
358        self.regions.get(&id)
359    }
360
361    pub(crate) fn region_mut_or_default(&mut self, id: WidgetId) -> &mut SelectionRegionState {
362        self.regions.entry(id).or_default()
363    }
364
365    pub(crate) fn clear_region(
366        &mut self,
367        id: WidgetId,
368        document: &crate::selection::RegionDocument,
369    ) {
370        for member in &document.members {
371            self.states.remove(&member.node_id);
372        }
373        if let Some(state) = self.regions.get_mut(&id) {
374            *state = SelectionRegionState::default();
375        }
376    }
377}
378
379#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
380pub(crate) enum SelectionGranularity {
381    #[default]
382    Character,
383    Word,
384    Paragraph,
385}
386
387#[derive(Clone, Debug, Default)]
388pub(crate) struct SelectionRegionState {
389    pub selection: Option<crate::selection::TextRegionSelection>,
390    pub selecting: bool,
391    pub granularity: SelectionGranularity,
392    pub pointer_down_at: Option<crate::time::CurrentTime>,
393    pub pointer_down_point: Option<LayoutPoint>,
394    pub pointer_kind: crate::event::PointerKind,
395    pub last_click_at: Option<crate::time::CurrentTime>,
396    pub last_click_point: Option<LayoutPoint>,
397    pub click_count: u8,
398    pub drag_started: bool,
399    pub caret_handle: Option<LayoutPoint>,
400    pub selection_start_handle: Option<LayoutPoint>,
401    pub selection_end_handle: Option<LayoutPoint>,
402    pub active_handle: Option<TextSelectionHandleKind>,
403    pub magnifier_visible: bool,
404    pub magnifier_anchor: Option<LayoutPoint>,
405}
406
407#[derive(Clone, Debug, Default)]
408pub struct SelectableTextState {
409    pub anchor: usize,
410    pub caret: usize,
411    pub selecting: bool,
412}
413
414impl SelectableTextState {
415    pub fn selection_range(&self) -> Option<(usize, usize)> {
416        if self.anchor == self.caret {
417            None
418        } else {
419            Some((self.anchor, self.caret))
420        }
421    }
422}
423
424#[derive(Clone, Debug, Default)]
425pub struct TextEditStateMap {
426    pub states: HashMap<WidgetId, TextEditState>,
427    pub restoration: HashMap<String, TextRestorationSnapshot>,
428}
429
430#[derive(Clone, Debug)]
431pub struct TextEditState {
432    pub buffer: TextBuffer,
433    pub caret: usize,  // byte index into value
434    pub anchor: usize, // selection anchor; if equal to caret then no selection
435    pub history: TextEditHistory,
436    pub preedit: Option<TextPreeditState>,
437    /// Composing range when a platform adapter synchronizes a complete value
438    /// whose composing text is already present in `buffer`.
439    pub platform_composing: Option<crate::TextRange>,
440    pub composition_base: Option<crate::TextEditingValue>,
441    pub pending_model_sync: bool, // True when edits are newer than the currently lowered semantics value
442    /// Last semantic model value observed for this input.
443    ///
444    /// While a local edit is pending, this lets the input distinguish "the app
445    /// has not observed the edit yet" from "the app observed it and produced a
446    /// transformed value".
447    pub last_model_text: String,
448    /// Last cursor position that was dispatched as a CursorChanged action.
449    /// Used to deduplicate dispatches and prevent unnecessary model updates
450    /// that could cause extra rebuild cycles.
451    pub last_dispatched_cursor: Option<(usize, usize)>,
452    pub affordances: TextInputAffordanceState,
453}
454
455impl Default for TextEditState {
456    fn default() -> Self {
457        Self {
458            buffer: TextBuffer::new(),
459            caret: 0,
460            anchor: 0,
461            history: TextEditHistory::default(),
462            preedit: None,
463            platform_composing: None,
464            composition_base: None,
465            pending_model_sync: false,
466            last_model_text: String::new(),
467            last_dispatched_cursor: None,
468            affordances: TextInputAffordanceState::default(),
469        }
470    }
471}
472
473#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
474pub enum TextSelectionHandleKind {
475    #[default]
476    Caret,
477    Start,
478    End,
479}
480
481#[derive(Clone, Debug, Default)]
482pub struct TextInputAffordanceState {
483    pub toolbar_visible: bool,
484    pub toolbar_anchor: Option<LayoutPoint>,
485    pub caret_handle: Option<LayoutPoint>,
486    pub selection_start_handle: Option<LayoutPoint>,
487    pub selection_end_handle: Option<LayoutPoint>,
488    pub active_handle: Option<TextSelectionHandleKind>,
489    pub magnifier_visible: bool,
490    pub magnifier_anchor: Option<LayoutPoint>,
491}
492
493#[derive(Clone, Debug)]
494pub struct TextPreeditState {
495    pub text: String,
496    pub range: (usize, usize),
497    pub cursor: Option<(usize, usize)>,
498}
499
500#[derive(Clone, Debug)]
501pub struct TextHistoryEntry {
502    pub transaction: EditTransaction,
503    pub before_caret: usize,
504    pub before_anchor: usize,
505    pub after_caret: usize,
506    pub after_anchor: usize,
507}
508
509#[derive(Clone, Debug)]
510pub struct TextRestorationSnapshot {
511    pub value: String,
512    pub caret: usize,
513    pub anchor: usize,
514}
515
516#[derive(Clone, Debug)]
517pub struct TextEditHistory {
518    pub undo_stack: Vec<TextHistoryEntry>,
519    pub redo_stack: Vec<TextHistoryEntry>,
520    pub capacity: usize, // Max undo steps
521}
522
523impl Default for TextEditHistory {
524    fn default() -> Self {
525        Self {
526            undo_stack: Vec::new(),
527            redo_stack: Vec::new(),
528            capacity: 100,
529        }
530    }
531}
532
533impl TextEditHistory {
534    pub fn record(&mut self, entry: TextHistoryEntry) {
535        self.undo_stack.push(entry);
536        if self.undo_stack.len() > self.capacity {
537            let overflow = self.undo_stack.len() - self.capacity;
538            self.undo_stack.drain(0..overflow);
539        }
540        self.redo_stack.clear();
541    }
542
543    pub fn undo(&mut self, buffer: &mut TextBuffer) -> Option<(usize, usize)> {
544        let entry = self.undo_stack.pop()?;
545        apply_transaction(buffer, &entry.transaction.inverse());
546        let caret = entry.before_caret;
547        let anchor = entry.before_anchor;
548        self.redo_stack.push(entry);
549        Some((caret, anchor))
550    }
551
552    pub fn redo(&mut self, buffer: &mut TextBuffer) -> Option<(usize, usize)> {
553        let entry = self.redo_stack.pop()?;
554        apply_transaction(buffer, &entry.transaction);
555        let caret = entry.after_caret;
556        let anchor = entry.after_anchor;
557        self.undo_stack.push(entry);
558        Some((caret, anchor))
559    }
560}
561
562fn apply_transaction(buffer: &mut TextBuffer, transaction: &EditTransaction) {
563    for edit in &transaction.edits {
564        buffer.replace(edit.range.clone(), &edit.new_text);
565    }
566}
567
568impl TextEditStateMap {
569    pub fn get_mut_or_default(&mut self, id: WidgetId) -> &mut TextEditState {
570        self.states.entry(id).or_default()
571    }
572    pub fn get(&self, id: WidgetId) -> Option<&TextEditState> {
573        self.states.get(&id)
574    }
575    pub fn sync_from_runtime(
576        &mut self,
577        id: WidgetId,
578        semantic_value: &str,
579        restoration_id: Option<&str>,
580        undo_capacity: Option<usize>,
581        masked: bool,
582    ) {
583        let restoration_snapshot = (!masked)
584            .then_some(restoration_id)
585            .flatten()
586            .and_then(|rid| {
587                if semantic_value.is_empty() {
588                    self.restoration.get(rid).cloned()
589                } else {
590                    None
591                }
592            });
593        let st = self.states.entry(id).or_default();
594        st.sync_from_model(semantic_value);
595        if semantic_value.is_empty() && st.buffer.len_bytes() == 0 {
596            if let Some(snapshot) = restoration_snapshot.as_ref() {
597                st.restore_snapshot(snapshot);
598            }
599        }
600        if let Some(capacity) = undo_capacity {
601            st.set_history_capacity(capacity);
602        }
603        if masked {
604            if let Some(rid) = restoration_id {
605                self.restoration.remove(rid);
606            }
607        } else if let Some(rid) = restoration_id {
608            self.restoration.insert(rid.to_string(), st.snapshot());
609        }
610    }
611    pub fn persist_restoration(
612        &mut self,
613        id: WidgetId,
614        restoration_id: Option<&str>,
615        masked: bool,
616    ) {
617        let Some(rid) = restoration_id else {
618            return;
619        };
620        if masked {
621            self.restoration.remove(rid);
622            return;
623        }
624        if let Some(st) = self.states.get(&id) {
625            self.restoration.insert(rid.to_string(), st.snapshot());
626        }
627    }
628    pub fn set_caret(&mut self, id: WidgetId, caret: usize, anchor: Option<usize>) {
629        let st = self.states.entry(id).or_default();
630        st.caret = caret;
631        st.anchor = anchor.unwrap_or(caret);
632        st.pending_model_sync = false;
633    }
634}
635
636impl TextEditState {
637    /// Returns the complete authoritative value for this editing session.
638    pub fn editing_value(&self) -> crate::TextEditingValue {
639        let (text, legacy_preedit_range) = self.display_text();
640        let (anchor, caret) = self
641            .preedit
642            .as_ref()
643            .map_or((self.anchor, self.caret), |preedit| {
644                let start = preedit.range.0.min(self.buffer.len_bytes());
645                preedit.cursor.map_or(
646                    (start + preedit.text.len(), start + preedit.text.len()),
647                    |(anchor, caret)| (start + anchor, start + caret),
648                )
649            });
650        let selection =
651            crate::TextSelection::new(&text, anchor, caret, crate::TextAffinity::Downstream)
652                .unwrap_or_else(|_| {
653                    crate::TextSelection::collapsed(crate::TextPosition::at_end(&text))
654                });
655        let composing = self.platform_composing.or_else(|| {
656            legacy_preedit_range
657                .and_then(|(start, end)| crate::TextRange::new(&text, start, end).ok())
658        });
659        crate::TextEditingValue {
660            text,
661            selection,
662            composing,
663        }
664    }
665
666    /// Atomically commits a complete value while recording one undo entry.
667    pub fn apply_editing_value(&mut self, value: crate::TextEditingValue) -> String {
668        if value.composing.is_none() {
669            if let Some(base) = self.composition_base.take() {
670                self.buffer = TextBuffer::from_str(&base.text);
671                self.caret = base.selection.extent.utf8_offset();
672                self.anchor = base.selection.base.utf8_offset();
673                self.platform_composing = None;
674            }
675        }
676        let old_len = self.buffer.len_bytes();
677        let caret = value.selection.extent.utf8_offset();
678        let anchor = value.selection.base.utf8_offset();
679        let composing = value.composing;
680        let text = self.apply_edit(0..old_len, &value.text, caret, anchor);
681        self.platform_composing = composing;
682        text
683    }
684
685    /// Synchronizes an in-progress platform composition without creating an
686    /// undo entry; the eventual composition commit is the atomic history step.
687    pub fn sync_composing_value(&mut self, value: crate::TextEditingValue) {
688        if self.composition_base.is_none() {
689            self.composition_base = Some(self.editing_value());
690        }
691        self.buffer = TextBuffer::from_str(&value.text);
692        self.caret = value.selection.extent.utf8_offset();
693        self.anchor = value.selection.base.utf8_offset();
694        self.preedit = None;
695        self.platform_composing = value.composing;
696        self.pending_model_sync = true;
697    }
698
699    pub fn snapshot(&self) -> TextRestorationSnapshot {
700        TextRestorationSnapshot {
701            value: self.buffer.to_string(),
702            caret: self.caret,
703            anchor: self.anchor,
704        }
705    }
706
707    pub fn restore_snapshot(&mut self, snapshot: &TextRestorationSnapshot) {
708        self.buffer = TextBuffer::from_str(&snapshot.value);
709        self.caret = snapshot.caret.min(snapshot.value.len());
710        self.anchor = snapshot.anchor.min(snapshot.value.len());
711        self.preedit = None;
712        self.platform_composing = None;
713        self.composition_base = None;
714        self.pending_model_sync = false;
715        self.last_model_text = snapshot.value.clone();
716        self.last_dispatched_cursor = None;
717        self.history = TextEditHistory::default();
718    }
719
720    pub fn set_history_capacity(&mut self, capacity: usize) {
721        let capacity = capacity.max(1);
722        self.history.capacity = capacity;
723        if self.history.undo_stack.len() > capacity {
724            let overflow = self.history.undo_stack.len() - capacity;
725            self.history.undo_stack.drain(0..overflow);
726        }
727        if self.history.redo_stack.len() > capacity {
728            let overflow = self.history.redo_stack.len() - capacity;
729            self.history.redo_stack.drain(0..overflow);
730        }
731    }
732
733    pub fn committed_text(&self) -> String {
734        self.buffer.to_string()
735    }
736
737    pub fn sync_from_model(&mut self, semantic_value: &str) {
738        let buffer_text = self.buffer.to_string();
739        if self.pending_model_sync {
740            if buffer_text == semantic_value {
741                self.pending_model_sync = false;
742                self.last_model_text = semantic_value.to_string();
743                return;
744            }
745            if semantic_value == self.last_model_text {
746                return;
747            }
748
749            let selection_was_collapsed = self.caret == self.anchor;
750            self.buffer = TextBuffer::from_str(semantic_value);
751            if selection_was_collapsed {
752                self.caret = semantic_value.len();
753                self.anchor = semantic_value.len();
754            } else {
755                self.caret = self.caret.min(semantic_value.len());
756                self.anchor = self.anchor.min(semantic_value.len());
757            }
758            self.preedit = None;
759            self.platform_composing = None;
760            self.composition_base = None;
761            self.history = TextEditHistory::default();
762            self.pending_model_sync = false;
763            self.last_model_text = semantic_value.to_string();
764            return;
765        }
766
767        if buffer_text != semantic_value {
768            self.buffer = TextBuffer::from_str(semantic_value);
769            self.caret = self.caret.min(semantic_value.len());
770            self.anchor = self.anchor.min(semantic_value.len());
771            self.preedit = None;
772            self.platform_composing = None;
773            self.composition_base = None;
774            self.history = TextEditHistory::default();
775        }
776        self.last_model_text = semantic_value.to_string();
777    }
778
779    pub fn selection_range(&self) -> (usize, usize) {
780        if self.caret <= self.anchor {
781            (self.caret, self.anchor)
782        } else {
783            (self.anchor, self.caret)
784        }
785    }
786
787    pub fn clear_preedit(&mut self) {
788        if let Some(base) = self.composition_base.take() {
789            self.buffer = TextBuffer::from_str(&base.text);
790            self.caret = base.selection.extent.utf8_offset();
791            self.anchor = base.selection.base.utf8_offset();
792        }
793        self.preedit = None;
794        self.platform_composing = None;
795    }
796
797    pub fn set_preedit(&mut self, text: String, cursor: Option<(usize, usize)>) {
798        self.platform_composing = None;
799        self.composition_base = None;
800        if text.is_empty() {
801            self.preedit = None;
802            return;
803        }
804        let cursor = normalize_preedit_cursor(&text, cursor);
805
806        if let Some(preedit) = &mut self.preedit {
807            preedit.text = text;
808            preedit.cursor = cursor;
809            return;
810        }
811
812        self.preedit = Some(TextPreeditState {
813            text,
814            range: self.selection_range(),
815            cursor,
816        });
817    }
818
819    pub fn display_text(&self) -> (String, Option<(usize, usize)>) {
820        let committed = self.buffer.to_string();
821        let Some(preedit) = &self.preedit else {
822            return (committed, None);
823        };
824
825        let start = preedit.range.0.min(committed.len());
826        let end = preedit.range.1.min(committed.len());
827
828        let mut display = String::with_capacity(
829            committed.len() - (end.saturating_sub(start)) + preedit.text.len(),
830        );
831        display.push_str(&committed[..start]);
832        display.push_str(&preedit.text);
833        display.push_str(&committed[end..]);
834        (display, Some((start, start + preedit.text.len())))
835    }
836
837    pub fn display_preedit_cursor_range(&self) -> Option<(usize, usize)> {
838        let preedit = self.preedit.as_ref()?;
839        let cursor = preedit.cursor?;
840        let start = preedit.range.0.min(self.buffer.len_bytes());
841        Some((start + cursor.0, start + cursor.1))
842    }
843
844    pub fn apply_edit(
845        &mut self,
846        range: std::ops::Range<usize>,
847        new_text: &str,
848        next_caret: usize,
849        next_anchor: usize,
850    ) -> String {
851        let buffer_len = self.buffer.len_bytes();
852        let start = range.start.min(buffer_len);
853        let end = range.end.min(buffer_len).max(start);
854        let range = start..end;
855        let old_text = self.buffer.slice(range.clone()).to_string();
856        let mut txn = EditTransaction::new();
857        txn.push(TextEdit::new(range, new_text, old_text));
858        apply_transaction(&mut self.buffer, &txn);
859        self.history.record(TextHistoryEntry {
860            transaction: txn,
861            before_caret: self.caret,
862            before_anchor: self.anchor,
863            after_caret: next_caret,
864            after_anchor: next_anchor,
865        });
866        self.caret = next_caret;
867        self.anchor = next_anchor;
868        self.preedit = None;
869        self.platform_composing = None;
870        self.composition_base = None;
871        self.pending_model_sync = true;
872        self.buffer.to_string()
873    }
874
875    pub fn undo(&mut self) -> Option<(String, usize, usize)> {
876        let (caret, anchor) = self.history.undo(&mut self.buffer)?;
877        self.caret = caret;
878        self.anchor = anchor;
879        self.preedit = None;
880        self.platform_composing = None;
881        self.composition_base = None;
882        self.pending_model_sync = true;
883        Some((self.buffer.to_string(), caret, anchor))
884    }
885
886    pub fn redo(&mut self) -> Option<(String, usize, usize)> {
887        let (caret, anchor) = self.history.redo(&mut self.buffer)?;
888        self.caret = caret;
889        self.anchor = anchor;
890        self.preedit = None;
891        self.platform_composing = None;
892        self.composition_base = None;
893        self.platform_composing = None;
894        self.pending_model_sync = true;
895        Some((self.buffer.to_string(), caret, anchor))
896    }
897}
898
899fn normalize_preedit_cursor(text: &str, cursor: Option<(usize, usize)>) -> Option<(usize, usize)> {
900    let (mut start, mut end) = cursor?;
901    start = start.min(text.len());
902    end = end.min(text.len());
903    if start > end {
904        std::mem::swap(&mut start, &mut end);
905    }
906    start = floor_char_boundary(text, start);
907    end = floor_char_boundary(text, end);
908    Some((start, end))
909}
910
911fn floor_char_boundary(text: &str, mut idx: usize) -> usize {
912    idx = idx.min(text.len());
913    while idx > 0 && !text.is_char_boundary(idx) {
914        idx -= 1;
915    }
916    idx
917}
918
919#[derive(Clone, Debug, Default)]
920pub struct InteractionStateMap {
921    pub hovered: HashMap<WidgetId, bool>,
922    pub hover_path: Vec<WidgetId>,
923    pub hover_rich_text_annotation: Option<HoveredRichTextAnnotation>,
924    pub pressed: HashMap<WidgetId, bool>,
925    pub focused: Option<WidgetId>,
926    pub cursor: MouseCursor,
927    pub last_down_point: Option<LayoutPoint>,
928}
929
930#[derive(Clone, Debug, PartialEq, Eq)]
931pub struct HoveredRichTextAnnotation {
932    pub node_id: WidgetId,
933    pub annotation: RichTextAnnotation,
934}
935
936impl InteractionStateMap {
937    pub fn is_hovered(&self, id: WidgetId) -> bool {
938        self.hovered.get(&id).copied().unwrap_or(false)
939    }
940    pub fn is_pressed(&self, id: WidgetId) -> bool {
941        self.pressed.get(&id).copied().unwrap_or(false)
942    }
943    pub fn is_focused(&self, id: WidgetId) -> bool {
944        self.focused == Some(id)
945    }
946
947    pub fn hovered_path(&self) -> &[WidgetId] {
948        &self.hover_path
949    }
950
951    pub fn hovered_rich_text_annotation(&self) -> Option<&HoveredRichTextAnnotation> {
952        self.hover_rich_text_annotation.as_ref()
953    }
954
955    pub fn cursor(&self) -> MouseCursor {
956        self.cursor
957    }
958
959    pub fn set_hovered(&mut self, id: WidgetId, value: bool) {
960        if value {
961            self.hovered.insert(id, true);
962        } else {
963            self.hovered.remove(&id);
964        }
965    }
966
967    pub fn set_hover_path(&mut self, path: Vec<WidgetId>) {
968        self.hover_path = path;
969    }
970
971    pub fn set_hovered_rich_text_annotation(
972        &mut self,
973        annotation: Option<HoveredRichTextAnnotation>,
974    ) {
975        self.hover_rich_text_annotation = annotation;
976    }
977
978    pub fn set_pressed(&mut self, id: WidgetId, value: bool) {
979        if value {
980            self.pressed.insert(id, true);
981        } else {
982            self.pressed.remove(&id);
983        }
984    }
985
986    pub fn set_focused(&mut self, id: Option<WidgetId>) {
987        self.focused = id;
988    }
989
990    pub fn set_cursor(&mut self, cursor: MouseCursor) {
991        self.cursor = cursor;
992    }
993}
994
995#[derive(Clone, Debug, Default)]
996pub struct VideoStateMap {
997    pub states: HashMap<WidgetId, VideoState>,
998}
999
1000#[derive(Clone, Debug, Default)]
1001pub struct WebState {
1002    pub url: String,
1003    pub user_agent: Option<String>,
1004    pub loading: bool,
1005    pub can_go_back: bool,
1006    pub can_go_forward: bool,
1007    pub title: Option<String>,
1008}
1009
1010#[derive(Clone, Debug, Default)]
1011pub struct WebStateMap {
1012    pub states: HashMap<WidgetId, WebState>,
1013}
1014
1015// Static environment data (Theme, I18n)
1016
1017impl GlobalState for VideoStateMap {}
1018
1019#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1020pub struct VideoState {
1021    pub status: VideoStatus,
1022    pub position_ms: u64,
1023    pub duration_ms: Option<u64>,
1024    pub rate: f32,
1025    pub volume: f32,
1026    pub muted: bool,
1027    pub looped: bool,
1028    pub asset_source: String,
1029    pub audio: VideoAudioOptions,
1030    pub surface_id: Option<u64>,
1031    pub pending_seek: Option<u64>,
1032}
1033
1034impl Default for VideoState {
1035    fn default() -> Self {
1036        Self {
1037            status: VideoStatus::Stopped,
1038            position_ms: 0,
1039            duration_ms: None,
1040            rate: 1.0,
1041            volume: 1.0,
1042            muted: false,
1043            looped: false,
1044            asset_source: String::new(),
1045            audio: VideoAudioOptions::default(),
1046            surface_id: None,
1047            pending_seek: None,
1048        }
1049    }
1050}
1051
1052#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
1053pub enum VideoStatus {
1054    Stopped,
1055    Playing,
1056    Paused,
1057    Buffering,
1058    Ended,
1059    Error,
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064    use super::*;
1065
1066    #[test]
1067    fn environment_exposes_a_safe_light_system_theme_default() {
1068        assert_eq!(Env::default().system_theme_mode, DesignMode::Light);
1069    }
1070
1071    #[test]
1072    fn logical_routes_parse_path_hash_and_query_consistently() {
1073        let path = RouteLocation::from_route("/projects/42?tab=activity#comments");
1074        assert_eq!(path.pathname, "/projects/42");
1075        assert_eq!(path.search.as_deref(), Some("?tab=activity"));
1076        assert_eq!(path.hash.as_deref(), Some("#comments"));
1077
1078        let hash = RouteLocation::from_route("#/projects/42?tab=activity");
1079        assert_eq!(hash.pathname, "/projects/42");
1080        assert_eq!(hash.search.as_deref(), Some("?tab=activity"));
1081        assert_eq!(hash.hash, None);
1082    }
1083
1084    #[test]
1085    fn complete_platform_composition_cancels_or_commits_as_one_undo_step() {
1086        let mut state = TextEditState::default();
1087        state.sync_from_model("ab");
1088        let composing_text = "a界b";
1089        let composing = crate::TextEditingValue::new(
1090            composing_text,
1091            crate::TextSelection::collapsed(
1092                crate::TextPosition::from_utf8(composing_text, 4).unwrap(),
1093            ),
1094            Some(crate::TextRange::new(composing_text, 1, 4).unwrap()),
1095        )
1096        .unwrap();
1097        state.sync_composing_value(composing.clone());
1098        assert_eq!(state.committed_text(), composing_text);
1099        state.clear_preedit();
1100        assert_eq!(state.committed_text(), "ab");
1101
1102        state.sync_composing_value(composing);
1103        let committed = crate::TextEditingValue::from_text(composing_text);
1104        state.apply_editing_value(committed);
1105        assert_eq!(state.committed_text(), composing_text);
1106        assert_eq!(state.undo().unwrap().0, "ab");
1107    }
1108}