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}
273
274#[derive(Clone, Debug)]
275pub struct TextHistoryEntry {
276    pub transaction: EditTransaction,
277    pub before_caret: usize,
278    pub before_anchor: usize,
279    pub after_caret: usize,
280    pub after_anchor: usize,
281}
282
283#[derive(Clone, Debug)]
284pub struct TextRestorationSnapshot {
285    pub value: String,
286    pub caret: usize,
287    pub anchor: usize,
288}
289
290#[derive(Clone, Debug)]
291pub struct TextEditHistory {
292    pub undo_stack: Vec<TextHistoryEntry>,
293    pub redo_stack: Vec<TextHistoryEntry>,
294    pub capacity: usize, // Max undo steps
295}
296
297impl Default for TextEditHistory {
298    fn default() -> Self {
299        Self {
300            undo_stack: Vec::new(),
301            redo_stack: Vec::new(),
302            capacity: 100,
303        }
304    }
305}
306
307impl TextEditHistory {
308    pub fn record(&mut self, entry: TextHistoryEntry) {
309        self.undo_stack.push(entry);
310        if self.undo_stack.len() > self.capacity {
311            let overflow = self.undo_stack.len() - self.capacity;
312            self.undo_stack.drain(0..overflow);
313        }
314        self.redo_stack.clear();
315    }
316
317    pub fn undo(&mut self, buffer: &mut TextBuffer) -> Option<(usize, usize)> {
318        let entry = self.undo_stack.pop()?;
319        apply_transaction(buffer, &entry.transaction.inverse());
320        let caret = entry.before_caret;
321        let anchor = entry.before_anchor;
322        self.redo_stack.push(entry);
323        Some((caret, anchor))
324    }
325
326    pub fn redo(&mut self, buffer: &mut TextBuffer) -> Option<(usize, usize)> {
327        let entry = self.redo_stack.pop()?;
328        apply_transaction(buffer, &entry.transaction);
329        let caret = entry.after_caret;
330        let anchor = entry.after_anchor;
331        self.undo_stack.push(entry);
332        Some((caret, anchor))
333    }
334}
335
336fn apply_transaction(buffer: &mut TextBuffer, transaction: &EditTransaction) {
337    for edit in &transaction.edits {
338        buffer.replace(edit.range.clone(), &edit.new_text);
339    }
340}
341
342impl TextEditStateMap {
343    pub fn get_mut_or_default(&mut self, id: WidgetId) -> &mut TextEditState {
344        self.states.entry(id).or_default()
345    }
346    pub fn get(&self, id: WidgetId) -> Option<&TextEditState> {
347        self.states.get(&id)
348    }
349    pub fn sync_from_runtime(
350        &mut self,
351        id: WidgetId,
352        semantic_value: &str,
353        restoration_id: Option<&str>,
354        undo_capacity: Option<usize>,
355    ) {
356        let restoration_snapshot = restoration_id.and_then(|rid| {
357            if semantic_value.is_empty() {
358                self.restoration.get(rid).cloned()
359            } else {
360                None
361            }
362        });
363        let st = self.states.entry(id).or_default();
364        st.sync_from_model(semantic_value);
365        if semantic_value.is_empty() && st.buffer.len_bytes() == 0 {
366            if let Some(snapshot) = restoration_snapshot.as_ref() {
367                st.restore_snapshot(snapshot);
368            }
369        }
370        if let Some(capacity) = undo_capacity {
371            st.set_history_capacity(capacity);
372        }
373        if let Some(rid) = restoration_id {
374            self.restoration.insert(rid.to_string(), st.snapshot());
375        }
376    }
377    pub fn persist_restoration(&mut self, id: WidgetId, restoration_id: Option<&str>) {
378        let Some(rid) = restoration_id else {
379            return;
380        };
381        if let Some(st) = self.states.get(&id) {
382            self.restoration.insert(rid.to_string(), st.snapshot());
383        }
384    }
385    pub fn set_caret(&mut self, id: WidgetId, caret: usize, anchor: Option<usize>) {
386        let st = self.states.entry(id).or_default();
387        st.caret = caret;
388        st.anchor = anchor.unwrap_or(caret);
389        st.pending_model_sync = false;
390    }
391}
392
393impl TextEditState {
394    pub fn snapshot(&self) -> TextRestorationSnapshot {
395        TextRestorationSnapshot {
396            value: self.buffer.to_string(),
397            caret: self.caret,
398            anchor: self.anchor,
399        }
400    }
401
402    pub fn restore_snapshot(&mut self, snapshot: &TextRestorationSnapshot) {
403        self.buffer = TextBuffer::from_str(&snapshot.value);
404        self.caret = snapshot.caret.min(snapshot.value.len());
405        self.anchor = snapshot.anchor.min(snapshot.value.len());
406        self.preedit = None;
407        self.pending_model_sync = false;
408        self.last_dispatched_cursor = None;
409        self.history = TextEditHistory::default();
410    }
411
412    pub fn set_history_capacity(&mut self, capacity: usize) {
413        let capacity = capacity.max(1);
414        self.history.capacity = capacity;
415        if self.history.undo_stack.len() > capacity {
416            let overflow = self.history.undo_stack.len() - capacity;
417            self.history.undo_stack.drain(0..overflow);
418        }
419        if self.history.redo_stack.len() > capacity {
420            let overflow = self.history.redo_stack.len() - capacity;
421            self.history.redo_stack.drain(0..overflow);
422        }
423    }
424
425    pub fn committed_text(&self) -> String {
426        self.buffer.to_string()
427    }
428
429    pub fn sync_from_model(&mut self, semantic_value: &str) {
430        if self.pending_model_sync && self.buffer.to_string() == semantic_value {
431            self.pending_model_sync = false;
432        }
433
434        if !self.pending_model_sync && self.buffer.to_string() != semantic_value {
435            self.buffer = TextBuffer::from_str(semantic_value);
436            self.caret = self.caret.min(semantic_value.len());
437            self.anchor = self.anchor.min(semantic_value.len());
438            self.preedit = None;
439            self.history = TextEditHistory::default();
440        }
441    }
442
443    pub fn selection_range(&self) -> (usize, usize) {
444        if self.caret <= self.anchor {
445            (self.caret, self.anchor)
446        } else {
447            (self.anchor, self.caret)
448        }
449    }
450
451    pub fn clear_preedit(&mut self) {
452        self.preedit = None;
453    }
454
455    pub fn set_preedit(&mut self, text: String) {
456        if text.is_empty() {
457            self.preedit = None;
458            return;
459        }
460
461        if let Some(preedit) = &mut self.preedit {
462            preedit.text = text;
463            return;
464        }
465
466        self.preedit = Some(TextPreeditState {
467            text,
468            range: self.selection_range(),
469        });
470    }
471
472    pub fn display_text(&self) -> (String, Option<(usize, usize)>) {
473        let committed = self.buffer.to_string();
474        let Some(preedit) = &self.preedit else {
475            return (committed, None);
476        };
477
478        let start = preedit.range.0.min(committed.len());
479        let end = preedit.range.1.min(committed.len());
480
481        let mut display = String::with_capacity(
482            committed.len() - (end.saturating_sub(start)) + preedit.text.len(),
483        );
484        display.push_str(&committed[..start]);
485        display.push_str(&preedit.text);
486        display.push_str(&committed[end..]);
487        (display, Some((start, start + preedit.text.len())))
488    }
489
490    pub fn apply_edit(
491        &mut self,
492        range: std::ops::Range<usize>,
493        new_text: &str,
494        next_caret: usize,
495        next_anchor: usize,
496    ) -> String {
497        let buffer_len = self.buffer.len_bytes();
498        let start = range.start.min(buffer_len);
499        let end = range.end.min(buffer_len).max(start);
500        let range = start..end;
501        let old_text = self.buffer.slice(range.clone()).to_string();
502        let mut txn = EditTransaction::new();
503        txn.push(TextEdit::new(range, new_text, old_text));
504        apply_transaction(&mut self.buffer, &txn);
505        self.history.record(TextHistoryEntry {
506            transaction: txn,
507            before_caret: self.caret,
508            before_anchor: self.anchor,
509            after_caret: next_caret,
510            after_anchor: next_anchor,
511        });
512        self.caret = next_caret;
513        self.anchor = next_anchor;
514        self.preedit = None;
515        self.pending_model_sync = true;
516        self.buffer.to_string()
517    }
518
519    pub fn undo(&mut self) -> Option<(String, usize, usize)> {
520        let (caret, anchor) = self.history.undo(&mut self.buffer)?;
521        self.caret = caret;
522        self.anchor = anchor;
523        self.preedit = None;
524        self.pending_model_sync = true;
525        Some((self.buffer.to_string(), caret, anchor))
526    }
527
528    pub fn redo(&mut self) -> Option<(String, usize, usize)> {
529        let (caret, anchor) = self.history.redo(&mut self.buffer)?;
530        self.caret = caret;
531        self.anchor = anchor;
532        self.preedit = None;
533        self.pending_model_sync = true;
534        Some((self.buffer.to_string(), caret, anchor))
535    }
536}
537
538#[derive(Clone, Debug, Default)]
539pub struct InteractionStateMap {
540    pub hovered: HashMap<WidgetId, bool>,
541    pub hover_path: Vec<WidgetId>,
542    pub hover_rich_text_annotation: Option<HoveredRichTextAnnotation>,
543    pub pressed: HashMap<WidgetId, bool>,
544    pub focused: Option<WidgetId>,
545    pub cursor: MouseCursor,
546    pub last_down_point: Option<LayoutPoint>,
547}
548
549#[derive(Clone, Debug, PartialEq, Eq)]
550pub struct HoveredRichTextAnnotation {
551    pub node_id: WidgetId,
552    pub annotation: RichTextAnnotation,
553}
554
555impl InteractionStateMap {
556    pub fn is_hovered(&self, id: WidgetId) -> bool {
557        self.hovered.get(&id).copied().unwrap_or(false)
558    }
559    pub fn is_pressed(&self, id: WidgetId) -> bool {
560        self.pressed.get(&id).copied().unwrap_or(false)
561    }
562    pub fn is_focused(&self, id: WidgetId) -> bool {
563        self.focused == Some(id)
564    }
565
566    pub fn hovered_path(&self) -> &[WidgetId] {
567        &self.hover_path
568    }
569
570    pub fn hovered_rich_text_annotation(&self) -> Option<&HoveredRichTextAnnotation> {
571        self.hover_rich_text_annotation.as_ref()
572    }
573
574    pub fn cursor(&self) -> MouseCursor {
575        self.cursor
576    }
577
578    pub fn set_hovered(&mut self, id: WidgetId, value: bool) {
579        if value {
580            self.hovered.insert(id, true);
581        } else {
582            self.hovered.remove(&id);
583        }
584    }
585
586    pub fn set_hover_path(&mut self, path: Vec<WidgetId>) {
587        self.hover_path = path;
588    }
589
590    pub fn set_hovered_rich_text_annotation(
591        &mut self,
592        annotation: Option<HoveredRichTextAnnotation>,
593    ) {
594        self.hover_rich_text_annotation = annotation;
595    }
596
597    pub fn set_pressed(&mut self, id: WidgetId, value: bool) {
598        if value {
599            self.pressed.insert(id, true);
600        } else {
601            self.pressed.remove(&id);
602        }
603    }
604
605    pub fn set_focused(&mut self, id: Option<WidgetId>) {
606        self.focused = id;
607    }
608
609    pub fn set_cursor(&mut self, cursor: MouseCursor) {
610        self.cursor = cursor;
611    }
612}
613
614#[derive(Clone, Debug, Default)]
615pub struct VideoStateMap {
616    pub states: HashMap<WidgetId, VideoState>,
617}
618
619#[derive(Clone, Debug, Default)]
620pub struct WebState {
621    pub url: String,
622    pub user_agent: Option<String>,
623    pub loading: bool,
624    pub can_go_back: bool,
625    pub can_go_forward: bool,
626    pub title: Option<String>,
627}
628
629#[derive(Clone, Debug, Default)]
630pub struct WebStateMap {
631    pub states: HashMap<WidgetId, WebState>,
632}
633
634// Static environment data (Theme, I18n)
635
636impl GlobalState for VideoStateMap {}
637
638#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
639pub struct VideoState {
640    pub status: VideoStatus,
641    pub position_ms: u64,
642    pub duration_ms: Option<u64>,
643    pub rate: f32,
644    pub volume: f32,
645    pub muted: bool,
646    pub looped: bool,
647    pub asset_source: String,
648    pub surface_id: Option<u64>,
649    pub pending_seek: Option<u64>,
650}
651
652impl Default for VideoState {
653    fn default() -> Self {
654        Self {
655            status: VideoStatus::Stopped,
656            position_ms: 0,
657            duration_ms: None,
658            rate: 1.0,
659            volume: 1.0,
660            muted: false,
661            looped: false,
662            asset_source: String::new(),
663            surface_id: None,
664            pending_seek: None,
665        }
666    }
667}
668
669#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
670pub enum VideoStatus {
671    Stopped,
672    Playing,
673    Paused,
674    Buffering,
675    Ended,
676    Error,
677}