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