Skip to main content

fission_core/
env.rs

1use crate::{action::GlobalState, motion::MotionStateMap, state::LocalStateStore};
2use fission_i18n::{I18nRegistry, Locale};
3use fission_ir::op::RichTextAnnotation;
4use fission_ir::semantics::MouseCursor;
5use fission_ir::WidgetId;
6use fission_layout::{LayoutPoint, LayoutSize};
7use fission_text_engine::{EditTransaction, TextBuffer, TextEdit};
8use fission_theme::Theme;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::sync::Arc;
12
13#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
14pub struct WindowInsets {
15    pub top: f32,
16    pub bottom: f32,
17    pub left: f32,
18    pub right: f32,
19}
20
21#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
22pub enum WindowTitle {
23    Plain(String),
24    // Rich(WindowTitleContent),
25}
26
27impl Default for WindowTitle {
28    fn default() -> Self {
29        Self::Plain("Fission".into())
30    }
31}
32
33impl WindowTitle {
34    pub fn plain(title: impl Into<String>) -> Self {
35        Self::Plain(title.into())
36    }
37
38    pub fn plain_text(&self) -> &str {
39        match self {
40            Self::Plain(title) => title,
41        }
42    }
43}
44
45#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
46pub struct WindowEnv {
47    pub title: WindowTitle,
48}
49
50/// Browser-compatible route location supplied by the host shell.
51///
52/// Only `pathname` is required. The remaining fields mirror `window.location`
53/// so desktop, web, and embedded hosts can pass richer navigation context
54/// without coupling applications to a specific shell implementation.
55#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
56pub struct RouteLocation {
57    pub pathname: String,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub host: Option<String>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub hash: Option<String>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub hostname: Option<String>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub href: Option<String>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub origin: Option<String>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub port: Option<String>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub protocol: Option<String>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub search: Option<String>,
74}
75
76impl Default for RouteLocation {
77    fn default() -> Self {
78        Self::new("/")
79    }
80}
81
82impl RouteLocation {
83    pub fn new(pathname: impl Into<String>) -> Self {
84        Self {
85            pathname: pathname.into(),
86            host: None,
87            hash: None,
88            hostname: None,
89            href: None,
90            origin: None,
91            port: None,
92            protocol: None,
93            search: None,
94        }
95    }
96}
97
98// Static environment data (Theme, I18n)
99#[derive(Clone)]
100pub struct Env {
101    pub theme: Theme,
102    pub i18n: I18nRegistry,
103    pub locale: Locale,
104    pub window: WindowEnv,
105    pub current_route: RouteLocation,
106    pub window_insets: WindowInsets,
107    pub viewport_size: LayoutSize,
108    pub measurer: Option<Arc<dyn fission_layout::TextMeasurer>>,
109}
110
111impl Default for Env {
112    fn default() -> Self {
113        Self {
114            theme: Theme::default(),
115            i18n: I18nRegistry::new(),
116            locale: Locale::default(),
117            window: WindowEnv::default(),
118            current_route: RouteLocation::default(),
119            window_insets: WindowInsets::default(),
120            viewport_size: LayoutSize::default(),
121            measurer: None,
122        }
123    }
124}
125
126impl std::fmt::Debug for Env {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.debug_struct("Env")
129            .field("theme", &self.theme)
130            .field("locale", &self.locale)
131            .field("window", &self.window)
132            .field("current_route", &self.current_route)
133            .field("window_insets", &self.window_insets)
134            .field("viewport_size", &self.viewport_size)
135            .finish()
136    }
137}
138
139impl Env {
140    pub fn new(measurer: Arc<dyn fission_layout::TextMeasurer>) -> Self {
141        Self {
142            theme: Theme::default(),
143            i18n: I18nRegistry::new(),
144            locale: Locale::default(),
145            window: WindowEnv::default(),
146            current_route: RouteLocation::default(),
147            window_insets: WindowInsets::default(),
148            viewport_size: LayoutSize::default(),
149            measurer: Some(measurer),
150        }
151    }
152}
153
154pub trait Clipboard: Send + Sync {
155    fn get_text(&self) -> Option<String>;
156    fn set_text(&self, text: &str);
157}
158
159pub trait ImeHandler: Send + Sync {
160    fn set_ime_allowed(&self, allowed: bool);
161    fn set_ime_cursor_area(&self, rect: fission_layout::LayoutRect);
162}
163
164// Runtime state managed by framework (Interaction)
165#[derive(Clone, Debug, Default)]
166pub struct RuntimeState {
167    pub local_widget_state: LocalStateStore,
168    pub scroll: ScrollStateMap,
169    pub video: VideoStateMap,
170    pub web: WebStateMap,
171    pub motion: MotionStateMap,
172    pub interaction: InteractionStateMap,
173    pub text_edit: TextEditStateMap,
174    pub clipboard: String,
175    pub caret_visible: HashMap<WidgetId, bool>,
176    pub gesture: GestureState,
177    pub hero: HeroState,
178}
179
180#[derive(Clone, Debug, Default)]
181pub struct HeroState {
182    // tag -> (Last Known WidgetId, Last Known Rect)
183    pub positions: HashMap<String, (WidgetId, fission_layout::LayoutRect)>,
184}
185
186#[derive(Clone, Debug, Default)]
187pub struct GestureState {
188    pub start_point: Option<LayoutPoint>,
189    pub last_point: Option<LayoutPoint>,
190    pub is_panning: bool,
191    pub target_node: Option<WidgetId>,
192    pub dragging_payload: Option<Vec<u8>>,
193    pub pressed_button: Option<crate::event::PointerButton>,
194    pub scrollbar_drag: Option<crate::scrollbar::ScrollbarDragState>,
195}
196
197#[derive(Clone, Debug, Default)]
198pub struct ScrollStateMap {
199    pub offsets: HashMap<WidgetId, f32>,
200}
201
202impl ScrollStateMap {
203    pub fn get_offset(&self, id: WidgetId) -> f32 {
204        *self.offsets.get(&id).unwrap_or(&0.0)
205    }
206
207    pub fn set_offset(&mut self, id: WidgetId, offset: f32) {
208        self.offsets.insert(id, offset);
209    }
210}
211
212#[derive(Clone, Debug, Default)]
213pub struct TextEditStateMap {
214    pub states: HashMap<WidgetId, TextEditState>,
215    pub restoration: HashMap<String, TextRestorationSnapshot>,
216}
217
218#[derive(Clone, Debug)]
219pub struct TextEditState {
220    pub buffer: TextBuffer,
221    pub caret: usize,  // byte index into value
222    pub anchor: usize, // selection anchor; if equal to caret then no selection
223    pub history: TextEditHistory,
224    pub preedit: Option<TextPreeditState>,
225    pub pending_model_sync: bool, // True when edits are newer than the currently lowered semantics value
226    /// Last cursor position that was dispatched as a CursorChanged action.
227    /// Used to deduplicate dispatches and prevent unnecessary model updates
228    /// that could cause extra rebuild cycles.
229    pub last_dispatched_cursor: Option<(usize, usize)>,
230    pub affordances: TextInputAffordanceState,
231}
232
233impl Default for TextEditState {
234    fn default() -> Self {
235        Self {
236            buffer: TextBuffer::new(),
237            caret: 0,
238            anchor: 0,
239            history: TextEditHistory::default(),
240            preedit: None,
241            pending_model_sync: false,
242            last_dispatched_cursor: None,
243            affordances: TextInputAffordanceState::default(),
244        }
245    }
246}
247
248#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
249pub enum TextSelectionHandleKind {
250    #[default]
251    Caret,
252    Start,
253    End,
254}
255
256#[derive(Clone, Debug, Default)]
257pub struct TextInputAffordanceState {
258    pub toolbar_visible: bool,
259    pub toolbar_anchor: Option<LayoutPoint>,
260    pub caret_handle: Option<LayoutPoint>,
261    pub selection_start_handle: Option<LayoutPoint>,
262    pub selection_end_handle: Option<LayoutPoint>,
263    pub active_handle: Option<TextSelectionHandleKind>,
264    pub magnifier_visible: bool,
265    pub magnifier_anchor: Option<LayoutPoint>,
266}
267
268#[derive(Clone, Debug)]
269pub struct TextPreeditState {
270    pub text: String,
271    pub range: (usize, usize),
272    pub cursor: Option<(usize, usize)>,
273}
274
275#[derive(Clone, Debug)]
276pub struct TextHistoryEntry {
277    pub transaction: EditTransaction,
278    pub before_caret: usize,
279    pub before_anchor: usize,
280    pub after_caret: usize,
281    pub after_anchor: usize,
282}
283
284#[derive(Clone, Debug)]
285pub struct TextRestorationSnapshot {
286    pub value: String,
287    pub caret: usize,
288    pub anchor: usize,
289}
290
291#[derive(Clone, Debug)]
292pub struct TextEditHistory {
293    pub undo_stack: Vec<TextHistoryEntry>,
294    pub redo_stack: Vec<TextHistoryEntry>,
295    pub capacity: usize, // Max undo steps
296}
297
298impl Default for TextEditHistory {
299    fn default() -> Self {
300        Self {
301            undo_stack: Vec::new(),
302            redo_stack: Vec::new(),
303            capacity: 100,
304        }
305    }
306}
307
308impl TextEditHistory {
309    pub fn record(&mut self, entry: TextHistoryEntry) {
310        self.undo_stack.push(entry);
311        if self.undo_stack.len() > self.capacity {
312            let overflow = self.undo_stack.len() - self.capacity;
313            self.undo_stack.drain(0..overflow);
314        }
315        self.redo_stack.clear();
316    }
317
318    pub fn undo(&mut self, buffer: &mut TextBuffer) -> Option<(usize, usize)> {
319        let entry = self.undo_stack.pop()?;
320        apply_transaction(buffer, &entry.transaction.inverse());
321        let caret = entry.before_caret;
322        let anchor = entry.before_anchor;
323        self.redo_stack.push(entry);
324        Some((caret, anchor))
325    }
326
327    pub fn redo(&mut self, buffer: &mut TextBuffer) -> Option<(usize, usize)> {
328        let entry = self.redo_stack.pop()?;
329        apply_transaction(buffer, &entry.transaction);
330        let caret = entry.after_caret;
331        let anchor = entry.after_anchor;
332        self.undo_stack.push(entry);
333        Some((caret, anchor))
334    }
335}
336
337fn apply_transaction(buffer: &mut TextBuffer, transaction: &EditTransaction) {
338    for edit in &transaction.edits {
339        buffer.replace(edit.range.clone(), &edit.new_text);
340    }
341}
342
343impl TextEditStateMap {
344    pub fn get_mut_or_default(&mut self, id: WidgetId) -> &mut TextEditState {
345        self.states.entry(id).or_default()
346    }
347    pub fn get(&self, id: WidgetId) -> Option<&TextEditState> {
348        self.states.get(&id)
349    }
350    pub fn sync_from_runtime(
351        &mut self,
352        id: WidgetId,
353        semantic_value: &str,
354        restoration_id: Option<&str>,
355        undo_capacity: Option<usize>,
356    ) {
357        let restoration_snapshot = restoration_id.and_then(|rid| {
358            if semantic_value.is_empty() {
359                self.restoration.get(rid).cloned()
360            } else {
361                None
362            }
363        });
364        let st = self.states.entry(id).or_default();
365        st.sync_from_model(semantic_value);
366        if semantic_value.is_empty() && st.buffer.len_bytes() == 0 {
367            if let Some(snapshot) = restoration_snapshot.as_ref() {
368                st.restore_snapshot(snapshot);
369            }
370        }
371        if let Some(capacity) = undo_capacity {
372            st.set_history_capacity(capacity);
373        }
374        if let Some(rid) = restoration_id {
375            self.restoration.insert(rid.to_string(), st.snapshot());
376        }
377    }
378    pub fn persist_restoration(&mut self, id: WidgetId, restoration_id: Option<&str>) {
379        let Some(rid) = restoration_id else {
380            return;
381        };
382        if let Some(st) = self.states.get(&id) {
383            self.restoration.insert(rid.to_string(), st.snapshot());
384        }
385    }
386    pub fn set_caret(&mut self, id: WidgetId, caret: usize, anchor: Option<usize>) {
387        let st = self.states.entry(id).or_default();
388        st.caret = caret;
389        st.anchor = anchor.unwrap_or(caret);
390        st.pending_model_sync = false;
391    }
392}
393
394impl TextEditState {
395    pub fn snapshot(&self) -> TextRestorationSnapshot {
396        TextRestorationSnapshot {
397            value: self.buffer.to_string(),
398            caret: self.caret,
399            anchor: self.anchor,
400        }
401    }
402
403    pub fn restore_snapshot(&mut self, snapshot: &TextRestorationSnapshot) {
404        self.buffer = TextBuffer::from_str(&snapshot.value);
405        self.caret = snapshot.caret.min(snapshot.value.len());
406        self.anchor = snapshot.anchor.min(snapshot.value.len());
407        self.preedit = None;
408        self.pending_model_sync = false;
409        self.last_dispatched_cursor = None;
410        self.history = TextEditHistory::default();
411    }
412
413    pub fn set_history_capacity(&mut self, capacity: usize) {
414        let capacity = capacity.max(1);
415        self.history.capacity = capacity;
416        if self.history.undo_stack.len() > capacity {
417            let overflow = self.history.undo_stack.len() - capacity;
418            self.history.undo_stack.drain(0..overflow);
419        }
420        if self.history.redo_stack.len() > capacity {
421            let overflow = self.history.redo_stack.len() - capacity;
422            self.history.redo_stack.drain(0..overflow);
423        }
424    }
425
426    pub fn committed_text(&self) -> String {
427        self.buffer.to_string()
428    }
429
430    pub fn sync_from_model(&mut self, semantic_value: &str) {
431        if self.pending_model_sync && self.buffer.to_string() == semantic_value {
432            self.pending_model_sync = false;
433        }
434
435        if !self.pending_model_sync && self.buffer.to_string() != semantic_value {
436            self.buffer = TextBuffer::from_str(semantic_value);
437            self.caret = self.caret.min(semantic_value.len());
438            self.anchor = self.anchor.min(semantic_value.len());
439            self.preedit = None;
440            self.history = TextEditHistory::default();
441        }
442    }
443
444    pub fn selection_range(&self) -> (usize, usize) {
445        if self.caret <= self.anchor {
446            (self.caret, self.anchor)
447        } else {
448            (self.anchor, self.caret)
449        }
450    }
451
452    pub fn clear_preedit(&mut self) {
453        self.preedit = None;
454    }
455
456    pub fn set_preedit(&mut self, text: String, cursor: Option<(usize, usize)>) {
457        if text.is_empty() {
458            self.preedit = None;
459            return;
460        }
461        let cursor = normalize_preedit_cursor(&text, cursor);
462
463        if let Some(preedit) = &mut self.preedit {
464            preedit.text = text;
465            preedit.cursor = cursor;
466            return;
467        }
468
469        self.preedit = Some(TextPreeditState {
470            text,
471            range: self.selection_range(),
472            cursor,
473        });
474    }
475
476    pub fn display_text(&self) -> (String, Option<(usize, usize)>) {
477        let committed = self.buffer.to_string();
478        let Some(preedit) = &self.preedit else {
479            return (committed, None);
480        };
481
482        let start = preedit.range.0.min(committed.len());
483        let end = preedit.range.1.min(committed.len());
484
485        let mut display = String::with_capacity(
486            committed.len() - (end.saturating_sub(start)) + preedit.text.len(),
487        );
488        display.push_str(&committed[..start]);
489        display.push_str(&preedit.text);
490        display.push_str(&committed[end..]);
491        (display, Some((start, start + preedit.text.len())))
492    }
493
494    pub fn display_preedit_cursor_range(&self) -> Option<(usize, usize)> {
495        let preedit = self.preedit.as_ref()?;
496        let cursor = preedit.cursor?;
497        let start = preedit.range.0.min(self.buffer.len_bytes());
498        Some((start + cursor.0, start + cursor.1))
499    }
500
501    pub fn apply_edit(
502        &mut self,
503        range: std::ops::Range<usize>,
504        new_text: &str,
505        next_caret: usize,
506        next_anchor: usize,
507    ) -> String {
508        let buffer_len = self.buffer.len_bytes();
509        let start = range.start.min(buffer_len);
510        let end = range.end.min(buffer_len).max(start);
511        let range = start..end;
512        let old_text = self.buffer.slice(range.clone()).to_string();
513        let mut txn = EditTransaction::new();
514        txn.push(TextEdit::new(range, new_text, old_text));
515        apply_transaction(&mut self.buffer, &txn);
516        self.history.record(TextHistoryEntry {
517            transaction: txn,
518            before_caret: self.caret,
519            before_anchor: self.anchor,
520            after_caret: next_caret,
521            after_anchor: next_anchor,
522        });
523        self.caret = next_caret;
524        self.anchor = next_anchor;
525        self.preedit = None;
526        self.pending_model_sync = true;
527        self.buffer.to_string()
528    }
529
530    pub fn undo(&mut self) -> Option<(String, usize, usize)> {
531        let (caret, anchor) = self.history.undo(&mut self.buffer)?;
532        self.caret = caret;
533        self.anchor = anchor;
534        self.preedit = None;
535        self.pending_model_sync = true;
536        Some((self.buffer.to_string(), caret, anchor))
537    }
538
539    pub fn redo(&mut self) -> Option<(String, usize, usize)> {
540        let (caret, anchor) = self.history.redo(&mut self.buffer)?;
541        self.caret = caret;
542        self.anchor = anchor;
543        self.preedit = None;
544        self.pending_model_sync = true;
545        Some((self.buffer.to_string(), caret, anchor))
546    }
547}
548
549fn normalize_preedit_cursor(text: &str, cursor: Option<(usize, usize)>) -> Option<(usize, usize)> {
550    let (mut start, mut end) = cursor?;
551    start = start.min(text.len());
552    end = end.min(text.len());
553    if start > end {
554        std::mem::swap(&mut start, &mut end);
555    }
556    start = floor_char_boundary(text, start);
557    end = floor_char_boundary(text, end);
558    Some((start, end))
559}
560
561fn floor_char_boundary(text: &str, mut idx: usize) -> usize {
562    idx = idx.min(text.len());
563    while idx > 0 && !text.is_char_boundary(idx) {
564        idx -= 1;
565    }
566    idx
567}
568
569#[derive(Clone, Debug, Default)]
570pub struct InteractionStateMap {
571    pub hovered: HashMap<WidgetId, bool>,
572    pub hover_path: Vec<WidgetId>,
573    pub hover_rich_text_annotation: Option<HoveredRichTextAnnotation>,
574    pub pressed: HashMap<WidgetId, bool>,
575    pub focused: Option<WidgetId>,
576    pub cursor: MouseCursor,
577    pub last_down_point: Option<LayoutPoint>,
578}
579
580#[derive(Clone, Debug, PartialEq, Eq)]
581pub struct HoveredRichTextAnnotation {
582    pub node_id: WidgetId,
583    pub annotation: RichTextAnnotation,
584}
585
586impl InteractionStateMap {
587    pub fn is_hovered(&self, id: WidgetId) -> bool {
588        self.hovered.get(&id).copied().unwrap_or(false)
589    }
590    pub fn is_pressed(&self, id: WidgetId) -> bool {
591        self.pressed.get(&id).copied().unwrap_or(false)
592    }
593    pub fn is_focused(&self, id: WidgetId) -> bool {
594        self.focused == Some(id)
595    }
596
597    pub fn hovered_path(&self) -> &[WidgetId] {
598        &self.hover_path
599    }
600
601    pub fn hovered_rich_text_annotation(&self) -> Option<&HoveredRichTextAnnotation> {
602        self.hover_rich_text_annotation.as_ref()
603    }
604
605    pub fn cursor(&self) -> MouseCursor {
606        self.cursor
607    }
608
609    pub fn set_hovered(&mut self, id: WidgetId, value: bool) {
610        if value {
611            self.hovered.insert(id, true);
612        } else {
613            self.hovered.remove(&id);
614        }
615    }
616
617    pub fn set_hover_path(&mut self, path: Vec<WidgetId>) {
618        self.hover_path = path;
619    }
620
621    pub fn set_hovered_rich_text_annotation(
622        &mut self,
623        annotation: Option<HoveredRichTextAnnotation>,
624    ) {
625        self.hover_rich_text_annotation = annotation;
626    }
627
628    pub fn set_pressed(&mut self, id: WidgetId, value: bool) {
629        if value {
630            self.pressed.insert(id, true);
631        } else {
632            self.pressed.remove(&id);
633        }
634    }
635
636    pub fn set_focused(&mut self, id: Option<WidgetId>) {
637        self.focused = id;
638    }
639
640    pub fn set_cursor(&mut self, cursor: MouseCursor) {
641        self.cursor = cursor;
642    }
643}
644
645#[derive(Clone, Debug, Default)]
646pub struct VideoStateMap {
647    pub states: HashMap<WidgetId, VideoState>,
648}
649
650#[derive(Clone, Debug, Default)]
651pub struct WebState {
652    pub url: String,
653    pub user_agent: Option<String>,
654    pub loading: bool,
655    pub can_go_back: bool,
656    pub can_go_forward: bool,
657    pub title: Option<String>,
658}
659
660#[derive(Clone, Debug, Default)]
661pub struct WebStateMap {
662    pub states: HashMap<WidgetId, WebState>,
663}
664
665// Static environment data (Theme, I18n)
666
667impl GlobalState for VideoStateMap {}
668
669#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
670pub struct VideoState {
671    pub status: VideoStatus,
672    pub position_ms: u64,
673    pub duration_ms: Option<u64>,
674    pub rate: f32,
675    pub volume: f32,
676    pub muted: bool,
677    pub looped: bool,
678    pub asset_source: String,
679    pub surface_id: Option<u64>,
680    pub pending_seek: Option<u64>,
681}
682
683impl Default for VideoState {
684    fn default() -> Self {
685        Self {
686            status: VideoStatus::Stopped,
687            position_ms: 0,
688            duration_ms: None,
689            rate: 1.0,
690            volume: 1.0,
691            muted: false,
692            looped: false,
693            asset_source: String::new(),
694            surface_id: None,
695            pending_seek: None,
696        }
697    }
698}
699
700#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
701pub enum VideoStatus {
702    Stopped,
703    Playing,
704    Paused,
705    Buffering,
706    Ended,
707    Error,
708}