Skip to main content

fission_core/
env.rs

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