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 }
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#[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 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 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#[derive(Clone)]
136pub struct Env {
137 pub theme: Theme,
138 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 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 fn set_editing_value(&self, _value: &crate::TextEditingValue) {}
215}
216
217#[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 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 pub modifiers: u8,
253 pub scrollbar_drag: Option<crate::scrollbar::ScrollbarDragState>,
254 pub drag_session: Option<DragSessionState>,
257}
258
259#[derive(Clone, Debug, PartialEq, Eq)]
261pub enum DragSessionPayload {
262 Internal(Vec<u8>),
264 ExternalFiles(Vec<String>),
266}
267
268impl DragSessionPayload {
269 pub fn kind(&self) -> &'static str {
271 match self {
272 Self::Internal(_) => "internal",
273 Self::ExternalFiles(_) => "files",
274 }
275 }
276}
277
278#[derive(Clone, Debug, PartialEq)]
280pub struct DragSessionState {
281 pub source_node: Option<WidgetId>,
283 pub source_identifier: Option<String>,
285 pub payload: DragSessionPayload,
287 pub point: LayoutPoint,
289 pub target_node: Option<WidgetId>,
291 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, pub anchor: usize, pub history: TextEditHistory,
436 pub preedit: Option<TextPreeditState>,
437 pub platform_composing: Option<crate::TextRange>,
440 pub composition_base: Option<crate::TextEditingValue>,
441 pub pending_model_sync: bool, pub last_model_text: String,
448 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, }
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 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 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 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
1015impl 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}