git.lucas.co / cce-text-editor
text editor
git clone https://git.lucas.co/cce-text-editor.git

src/main.rs (29.2K)

  1 use cce_ui::widget::Handle;
  2 use cce_ui::cosmic_text::FontSystem;
  3 use cce_ui::engine::{Application, LogicalPosition, LogicalSize, WindowSettings};
  4 use cce_ui::widget::{
  5     MouseButton, ElementState, MouseScrollDelta, KeyEvent, WidgetHost,
  6     TextBox, Key, Dropdown
  7 };
  8 
  9 /// The menubar and status bands' heights (sizes, not spacing — the ladder
 10 /// supplies the insets and gaps around and inside them).
 11 const MENUBAR_H: f32 = 42.0;
 12 const STATUSBAR_H: f32 = 30.0;
 13 
 14 #[derive(Debug, Clone)]
 15 enum AppMessage {
 16     Exit,
 17     NewDocument,
 18     OpenDocument,
 19     SaveDocument,
 20     SaveDocumentAs,
 21 }
 22 
 23 /// App shortcuts, resolved once at startup from input.kdl
 24 /// (`cce-text-editor` domain → `cce-ui` domain).
 25 struct EditorKeys {
 26     new_document: String,
 27     open_document: String,
 28     save_document: String,
 29     quit: String,
 30 }
 31 
 32 impl EditorKeys {
 33     fn load() -> Self {
 34         let get = cce_ui::input::app_chord;
 35         Self {
 36             new_document: get("new_document", "ctrl+n"),
 37             open_document: get("open_document", "ctrl+o"),
 38             save_document: get("save_document", "ctrl+s"),
 39             quit: get("quit", "ctrl+q"),
 40         }
 41     }
 42 }
 43 
 44 struct TextEditorApp {
 45     keys: EditorKeys,
 46 
 47     // File menu dropdown
 48     menu_dropdown: Handle<cce_ui::widget::Adapted<Dropdown>>,
 49     
 50     // Editor TextBox
 51     editor: Handle<cce_ui::widget::Adapted<TextBox>>,
 52     
 53     // File state
 54     current_file_path: Option<std::path::PathBuf>,
 55     
 56     // UI state
 57     width: u32,
 58     height: u32,
 59     scale_factor: f64,
 60     // Shapes the editor's glyph advances (prepare_text) — load-bearing for cursor↔pixel
 61     // mapping; all rendered text is display-list prims shaped by the engine.
 62     font_system: FontSystem,
 63     needs_rebuild: bool,
 64     ui_context: cce_ui::context::UiContext,
 65     ctrl_pressed: bool,
 66     initial_focus: bool,
 67     status_message: Option<(String, bool)>,
 68 }
 69 
 70 impl TextEditorApp {
 71     fn pick_file_to_open(&self) -> Option<std::path::PathBuf> {
 72         let output = std::process::Command::new("/home/lsgalante/.local/bin/cce-files")
 73             .arg("--select")
 74             .output()
 75             .or_else(|_| {
 76                 std::process::Command::new("cce-files")
 77                     .arg("--select")
 78                     .output()
 79             })
 80             .ok()?;
 81         
 82         if output.status.success() {
 83             let stdout = String::from_utf8_lossy(&output.stdout);
 84             let trimmed = stdout.trim();
 85             if !trimmed.is_empty() {
 86                 return Some(std::path::PathBuf::from(trimmed));
 87             }
 88         }
 89         None
 90     }
 91 
 92     fn perform_save_as(&mut self, needs_rebuild: &mut bool) {
 93         let path_opt = std::process::Command::new("/home/lsgalante/.local/bin/cce-files")
 94             .arg("--save")
 95             .output()
 96             .or_else(|_| {
 97                 std::process::Command::new("cce-files")
 98                     .arg("--save")
 99                     .output()
100             })
101             .ok()
102             .and_then(|output| {
103                 if output.status.success() {
104                     let stdout = String::from_utf8_lossy(&output.stdout);
105                     let trimmed = stdout.trim();
106                     if !trimmed.is_empty() {
107                         Some(std::path::PathBuf::from(trimmed))
108                     } else {
109                         None
110                     }
111                 } else {
112                     None
113                 }
114             });
115 
116         if let Some(path) = path_opt {
117             let editor = &self.ui_context[self.editor];
118             let content = if editor.editing { &editor.edit_buffer } else { &editor.text };
119             match std::fs::write(&path, content) {
120                 Ok(_) => {
121                     self.current_file_path = Some(path.clone());
122                     if self.ui_context[self.editor].editing {
123                         self.ui_context[self.editor].text = self.ui_context[self.editor].edit_buffer.clone();
124                     }
125                     self.status_message = Some((format!("Saved successfully to {}", path.file_name().unwrap_or_default().to_string_lossy()), false));
126                     *needs_rebuild = true;
127                     self.needs_rebuild = true;
128                 }
129                 Err(e) => {
130                     self.status_message = Some((format!("Error saving file: {}", e), true));
131                     *needs_rebuild = true;
132                     self.needs_rebuild = true;
133                 }
134             }
135         }
136     }
137 
138     /// The toolbar/status-bar chrome text — everything not owned by a widget (widget text
139     /// comes from the paint walk). Emitted as display-list prims in the system monospace
140     /// family, matching the app's legacy hand-shaped look.
141     fn push_chrome_text(&self, pc: &mut cce_ui::scene::paint::PaintCtx) {
142         let mono = || Some(cce_ui::layout::get_system_monospace_font().to_string());
143 
144         // 1. File path info in the toolbar
145         let is_dirty = if self.ui_context[self.editor].editing {
146             self.ui_context[self.editor].text != self.ui_context[self.editor].edit_buffer
147         } else {
148             false
149         };
150         let file_name_str = match &self.current_file_path {
151             Some(path) => path.file_name().unwrap_or_default().to_string_lossy().into_owned(),
152             None => "Untitled".to_string(),
153         };
154         let display_name = if is_dirty {
155             format!("*{}", file_name_str)
156         } else {
157             file_name_str
158         };
159         // TODO(style): an absolute x for the file label; it should sit one
160         // `root_plate_gap` after the menu's solved rect, like a toolbar sibling.
161         pc.text_with(format!("File: {}", display_name), 420.0, 15.0, 12.0, [0xdd, 0xdd, 0xe2], mono(), None);
162 
163         // 2. Status Bar indicators
164         let text_src = if self.ui_context[self.editor].editing { &self.ui_context[self.editor].edit_buffer } else { &self.ui_context[self.editor].text };
165         let mut logical_line = 1;
166         let mut logical_col = 1;
167         for (idx, ch) in text_src.chars().enumerate() {
168             if idx >= self.ui_context[self.editor].cursor_idx {
169                 break;
170             }
171             if ch == '\n' {
172                 logical_line += 1;
173                 logical_col = 1;
174             } else {
175                 logical_col += 1;
176             }
177         }
178         pc.text_with(
179             format!("Line: {}, Col: {} | Length: {} chars", logical_line, logical_col, text_src.chars().count()),
180             cce_ui::layout::root_plate_inset(),
181             self.height as f32 - 20.0,
182             11.0,
183             [0x83, 0x83, 0x8a],
184             mono(),
185             None,
186         );
187 
188         if let Some((msg, is_error)) = &self.status_message {
189             let color = if *is_error { [0xfa, 0x52, 0x52] } else { [0x40, 0xc0, 0x57] };
190             pc.text_with(
191                 msg.clone(),
192                 (self.width as f32 - 400.0).max(300.0),
193                 self.height as f32 - 20.0,
194                 11.0,
195                 color,
196                 mono(),
197                 None,
198             );
199         }
200     }
201 }
202 
203 impl Application for TextEditorApp {
204     type Message = AppMessage;
205 
206     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
207         Some(&self.ui_context)
208     }
209 
210     fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
211         Some(&mut self.ui_context)
212     }
213 
214     fn create(_sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
215         let dropdown_options = vec![
216             "New".to_string(),
217             "Open...".to_string(),
218             "Save".to_string(),
219             "Save As...".to_string(),
220             "-".to_string(),
221             "Exit".to_string(),
222         ];
223         let mut menu_dropdown = Dropdown::new(dropdown_options, 0).with_custom_display_text("File");
224         // Placeholder rect until the first frame's layout solve assigns the real one:
225         // inset from the window edge by the root rung, centred in the 42px menubar band.
226         let dd_h = cce_ui::layout::dropdown_height();
227         menu_dropdown.set_rect(cce_ui::layout::root_plate_inset(), (MENUBAR_H - dd_h) / 2.0, 70.0, dd_h);
228 
229         // Monospace textbox setup
230         let mut editor = TextBox::new(String::new())
231             .with_multiline(true)
232             .with_draw_bg_border(true)
233             .with_max_width(None);
234         editor.font_family = "monospace".to_string();
235         editor.font_size = 13.0;
236 
237         // Auto-open path if passed as argv[1]
238         let args: Vec<String> = std::env::args().collect();
239         let mut current_file_path = None;
240         if args.len() > 1 {
241             let path = std::path::PathBuf::from(&args[1]);
242             if path.exists() {
243                 if let Ok(content) = std::fs::read_to_string(&path) {
244                     editor.text = content;
245                     editor.edit_buffer = editor.text.clone();
246                     current_file_path = Some(path);
247                 }
248             }
249         }
250 
251         // The context owns the widgets; the app keeps their handles.
252         let mut ui_context = cce_ui::context::UiContext::new();
253         Self {
254             keys: EditorKeys::load(),
255             menu_dropdown: ui_context.insert(menu_dropdown),
256             editor: ui_context.insert(editor),
257             current_file_path,
258             width: 800,
259             height: 600,
260             scale_factor: 1.0,
261             font_system: cce_ui::create_font_system_with_system_fonts(),
262             needs_rebuild: true,
263             ui_context,
264             ctrl_pressed: false,
265             initial_focus: true,
266             status_message: None,
267         }
268     }
269 
270     fn settings(&self) -> WindowSettings {
271         WindowSettings {
272             title: "Clear Text Editor".to_string(),
273             app_id: "cce-text-editor".to_string(),
274             width: 800,
275             height: 600,
276             fullscreen: false,
277             min_size: Some((500, 400)),
278         }
279     }
280 
281     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
282         match msg {
283             AppMessage::Exit => {
284                 *exit = true;
285             }
286             AppMessage::NewDocument => {
287                 let editor = &mut self.ui_context[self.editor];
288                 editor.text = String::new();
289                 editor.edit_buffer = String::new();
290                 editor.editing = false;
291                 editor.cursor_idx = 0;
292                 editor.select_anchor = None;
293                 self.current_file_path = None;
294                 self.status_message = None;
295 
296                 self.ui_context.set_focused_id(self.editor.id());
297                 WidgetHost::focus(&mut self.ui_context[self.editor]);
298 
299                 *needs_rebuild = true;
300                 self.needs_rebuild = true;
301             }
302             AppMessage::OpenDocument => {
303                 if let Some(path) = self.pick_file_to_open() {
304                     match std::fs::read_to_string(&path) {
305                         Ok(content) => {
306                             let editor = &mut self.ui_context[self.editor];
307                             editor.text = content;
308                             editor.edit_buffer = editor.text.clone();
309                             editor.cursor_idx = 0;
310                             editor.select_anchor = None;
311                             editor.editing = false;
312                             self.current_file_path = Some(path.clone());
313                             self.status_message = Some((format!("Opened {}", path.file_name().unwrap_or_default().to_string_lossy()), false));
314 
315                             self.ui_context.set_focused_id(self.editor.id());
316                             WidgetHost::focus(&mut self.ui_context[self.editor]);
317                         }
318                         Err(e) => {
319                             self.status_message = Some((format!("Error opening file: {}", e), true));
320                         }
321                     }
322                     *needs_rebuild = true;
323                     self.needs_rebuild = true;
324                 }
325             }
326             AppMessage::SaveDocument => {
327                 if self.current_file_path.is_some() {
328                     let path = self.current_file_path.clone().unwrap();
329                     let content = if self.ui_context[self.editor].editing { &self.ui_context[self.editor].edit_buffer } else { &self.ui_context[self.editor].text };
330                     match std::fs::write(&path, content) {
331                         Ok(_) => {
332                             if self.ui_context[self.editor].editing {
333                                 self.ui_context[self.editor].text = self.ui_context[self.editor].edit_buffer.clone();
334                             }
335                             self.status_message = Some((format!("Saved successfully to {}", path.file_name().unwrap_or_default().to_string_lossy()), false));
336                         }
337                         Err(e) => {
338                             self.status_message = Some((format!("Error saving file: {}", e), true));
339                         }
340                     }
341                     *needs_rebuild = true;
342                     self.needs_rebuild = true;
343                 } else {
344                     self.perform_save_as(needs_rebuild);
345                 }
346             }
347             AppMessage::SaveDocumentAs => {
348                 self.perform_save_as(needs_rebuild);
349             }
350         }
351     }
352 
353     fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
354 
355     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
356         // Phase 6 single paint path: the whole frame — chrome geometry, chrome text, and the
357         // two top-level widgets (menu_dropdown, editor) walked into the list — is built here.
358         // Widget text comes from the paint walk (Adapted::paint_self serves per-widget fonts).
359         if self.initial_focus {
360             self.initial_focus = false;
361             self.ui_context.set_focused_id(self.editor.id());
362             WidgetHost::focus(&mut self.ui_context[self.editor]);
363             self.needs_rebuild = true;
364         }
365         let size_changed = self.width != size.width as u32 || self.height != size.height as u32 || self.scale_factor != scale;
366         if self.needs_rebuild || size_changed {
367             self.width = size.width as u32;
368             self.height = size.height as u32;
369             self.scale_factor = scale;
370 
371             // Layout via the scene solver (Phase 6ab — the routed-events/scene-layout
372             // reference): the frame is a stretched column [menubar band (fixed
373             // MENUBAR_H, holding the fixed menu leaf), content (grow, holding the
374             // editor), status band (fixed STATUSBAR_H)].
375             //
376             // Spacing by rung, never by number. The root column itself carries no
377             // inset: both bands are flush to the window edge by design (the
378             // MenuBar/StatusBar band idiom), so the root rung is applied to what
379             // stands between and inside them instead — the menu and the editor
380             // inset from the window's sides by `root_plate_inset`, and the editor
381             // stands off each band by `root_plate_gap`, the gap between siblings
382             // on the root plate. The menu centres in its band rather than carrying
383             // a vertical padding.
384             {
385                 use cce_ui::scene::arena::Arena;
386                 use cce_ui::scene::layout::{
387                     compute_layout, CrossAlign, Edges, LayoutBox, Length, Size as LSize, Style,
388                 };
389                 let inset = cce_ui::layout::root_plate_inset();
390                 let gap = cce_ui::layout::root_plate_gap();
391                 let mut arena: Arena<LayoutBox> = Arena::new();
392                 let root = arena.insert(LayoutBox::container(
393                     Style::column().cross_align(CrossAlign::Stretch),
394                 ));
395                 let top_bar = arena.insert(LayoutBox::container({
396                     let mut s = Style::row()
397                         .height(Length::Fixed(MENUBAR_H))
398                         .gap(gap)
399                         .cross_align(CrossAlign::Center);
400                     s.padding = Edges { left: inset, right: inset, top: 0.0, bottom: 0.0 };
401                     s
402                 }));
403                 let menu = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(70.0, cce_ui::layout::dropdown_height())));
404                 let content = arena.insert(LayoutBox::container({
405                     let mut s = Style::column().grow(1.0).cross_align(CrossAlign::Stretch);
406                     s.padding = Edges { left: inset, right: inset, top: gap, bottom: gap };
407                     s
408                 }));
409                 let editor = arena.insert(LayoutBox::container({
410                     let mut s = Style::column().grow(1.0);
411                     s.min_width = 100.0;
412                     s.min_height = 100.0;
413                     s
414                 }));
415                 let status = arena.insert(LayoutBox::container(
416                     Style::column().height(Length::Fixed(STATUSBAR_H)),
417                 ));
418                 arena.append_child(root, top_bar);
419                 arena.append_child(top_bar, menu);
420                 arena.append_child(root, content);
421                 arena.append_child(content, editor);
422                 arena.append_child(root, status);
423                 compute_layout(&mut arena, root, LSize::new(self.width as f32, self.height as f32));
424                 let m = arena.value(menu).unwrap().rect;
425                 self.ui_context[self.menu_dropdown].set_rect(m.x, m.y, m.width, m.height);
426                 let e = arena.value(editor).unwrap().rect;
427                 self.ui_context[self.editor].set_rect(e.x, e.y, e.width, e.height);
428             }
429 
430             // Glyph-advance shaping — load-bearing for cursor↔pixel mapping.
431             self.ui_context[self.editor].prepare_text(&mut self.font_system);
432             self.needs_rebuild = false;
433 
434             self.ui_context.rebuild_spatial_grid();
435         }
436 
437         // Popover registration — ui_context ONLY (drives the engine's dl-text occlusion
438         // clamp). The popover itself draws into this display list below; the global
439         // registry fed the engine's render-only xdg popup, which this app no longer uses.
440         self.ui_context.clear_popovers();
441         if self.ui_context[self.menu_dropdown].popover_rect().is_some() {
442             self.ui_context.register_popover_id(self.menu_dropdown.id());
443         }
444 
445         use cce_ui::scene::layout::Rect;
446         let mut pc = cce_ui::scene::paint::PaintCtx::new();
447         let w = self.width as f32;
448         let h = self.height as f32;
449         let status_y = h - STATUSBAR_H;
450         // The standard root plate (cce-ui PlateSpec::window).
451         pc.root_plate(w, h);
452         // Silhouette radius (cce-ui RFC 7b): matches the compositor clip.
453         // TODO(style): these bands are flat fills over the plate; the toolkit's
454         // idiom for a menubar/status band is a carve (`recess_edges` with
455         // `bar_wall_width`, one wall facing the content, as cce-ui's demo does).
456         // Switching drops the bands' own fill colour, so it is a look change,
457         // not a spacing one, and is left for a deliberate pass.
458         let radius = cce_ui::layout::window_silhouette_radius();
459         if radius > 0.1 {
460             pc.rounded_rect(Rect { x: 0.0, y: 0.0, width: w, height: MENUBAR_H }, radius, (true, true, false, false), [0.08, 0.08, 0.12, 1.0]);
461             pc.rounded_rect(Rect { x: 0.0, y: status_y, width: w, height: STATUSBAR_H }, radius, (false, false, true, true), [0.08, 0.08, 0.10, 1.0]);
462         } else {
463             pc.quad(Rect { x: 0.0, y: 0.0, width: w, height: MENUBAR_H }, [0.08, 0.08, 0.12, 1.0]);
464             pc.quad(Rect { x: 0.0, y: status_y, width: w, height: STATUSBAR_H }, [0.08, 0.08, 0.10, 1.0]);
465         }
466         pc.quad(Rect { x: 0.0, y: MENUBAR_H, width: w, height: 1.0 }, [0.18, 0.18, 0.22, 1.0]);
467         pc.quad(Rect { x: 0.0, y: status_y, width: w, height: 1.0 }, [0.18, 0.18, 0.22, 1.0]);
468 
469         self.push_chrome_text(&mut pc);
470 
471         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.menu_dropdown], &mut pc);
472         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.editor], &mut pc);
473 
474         // The menu popover — geometry and labels last, on top of everything, exactly where
475         // it hit-tests (the engine xdg popup is gone). Labels carry bounds equal to the
476         // popover rect: clips them to the plate and exempts them from the occlusion clamp
477         // (the is-overlay-text convention).
478         if self.ui_context[self.menu_dropdown].popover_rect().is_some() {
479             // PaintCtx is a RenderTarget: the popover draws its real prims (the
480             // dropdown's expanded inset-plate surface) with its own bounds.
481             self.ui_context[self.menu_dropdown].render_popover(&mut pc);
482         }
483         Some(pc.finish())
484     }
485 
486     fn display_list_text(&self) -> bool {
487         true
488     }
489 
490     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
491         let mut changed = false;
492         let px = pos.x as f32;
493         let py = pos.y as f32;
494 
495         // The shared context menu (the editor's, the File menu's) gets the
496         // pointer to itself while open: its row highlight.
497         if cce_ui::widget::context_menu::is_visible() {
498             if cce_ui::widget::context_menu::cursor_moved(px, py) {
499                 *needs_rebuild = true;
500                 self.needs_rebuild = true;
501             }
502             return;
503         }
504 
505         // Routed dispatch (Phase 6ab): one Event through the UiContext router per root;
506         // PointerMove visits both (hover bookkeeping + the router's drag forwarding).
507         let ev = cce_ui::widget::Event::PointerMove { x: px, y: py, local_x: px, local_y: py };
508         let menu = self.menu_dropdown.id();
509         let editor = self.editor.id();
510         if self.ui_context.propagate_event(&ev, menu) { changed = true; }
511         if self.ui_context.propagate_event(&ev, editor) { changed = true; }
512 
513         if changed {
514             *needs_rebuild = true;
515             self.needs_rebuild = true;
516         }
517     }
518 
519     fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message> {
520         let mut changed = false;
521         let mut msg_out = None;
522         let px = pos.x as f32;
523         let py = pos.y as f32;
524 
525         // The shared context menu a right-click on the editor or the File menu
526         // opens takes every click while open, ahead of the File menu's own list:
527         // a row (Cut / Copy / Paste / Select All) runs, a press anywhere else
528         // dismisses it. The toolkit leaves this routing to the app; without it
529         // the menu could not be closed by clicking outside it, and its rows did
530         // nothing.
531         if cce_ui::widget::context_menu::is_visible() {
532             if cce_ui::widget::context_menu::mouse_input(button, state, px, py, Some(&mut self.ui_context)) {
533                 *needs_rebuild = true;
534                 self.needs_rebuild = true;
535             }
536             return None;
537         }
538 
539         // Routed dispatch (Phase 6ab): the router hit-gates presses, synthesizes
540         // Enter/Leave, and records drag targets; the app keeps only take_change plumbing.
541         let ev = cce_ui::widget::Event::MouseButton { button, state, x: px, y: py, local_x: px, local_y: py };
542         let menu = self.menu_dropdown.id();
543         let editor = self.editor.id();
544         if self.ui_context.propagate_event(&ev, menu) {
545             changed = true;
546             if self.ui_context[self.menu_dropdown].take_change() {
547                 msg_out = self.menu_action();
548             }
549         } else if self.ui_context.propagate_event(&ev, editor) {
550             changed = true;
551         } else if state == ElementState::Pressed && button == MouseButton::Left {
552             self.ui_context.unfocus_id(self.editor.id());
553             changed = true;
554         }
555 
556         if changed || msg_out.is_some() {
557             *needs_rebuild = true;
558             self.needs_rebuild = true;
559         }
560 
561         msg_out
562     }
563 
564     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
565         if self.ctrl_pressed {
566             match delta {
567                 MouseScrollDelta::LineDelta(_, y) => {
568                     if *y > 0.0 {
569                         self.ui_context[self.editor].font_size = (self.ui_context[self.editor].font_size + 1.0).min(72.0);
570                     } else if *y < 0.0 {
571                         self.ui_context[self.editor].font_size = (self.ui_context[self.editor].font_size - 1.0).max(6.0);
572                     }
573                     *needs_rebuild = true;
574                     self.needs_rebuild = true;
575                 }
576                 MouseScrollDelta::PixelDelta(pos) => {
577                     if pos.y > 0.0 {
578                         self.ui_context[self.editor].font_size = (self.ui_context[self.editor].font_size + 1.0).min(72.0);
579                     } else if pos.y < 0.0 {
580                         self.ui_context[self.editor].font_size = (self.ui_context[self.editor].font_size - 1.0).max(6.0);
581                     }
582                     *needs_rebuild = true;
583                     self.needs_rebuild = true;
584                 }
585             }
586         } else {
587             // Plain wheel: routed to the editor, whose TextBox owns the
588             // document scroll (glide and coast included — its tick runs
589             // through the runner's ui_context tick). Nothing forwarded it
590             // before, so the wheel over the document was dead.
591             let px = pos.x as f32;
592             let py = pos.y as f32;
593             let ev = cce_ui::widget::Event::MouseWheel { delta: *delta, x: px, y: py, local_x: px, local_y: py };
594             let editor = self.editor.id();
595             if self.ui_context.propagate_event(&ev, editor) {
596                 *needs_rebuild = true;
597                 self.needs_rebuild = true;
598             }
599         }
600     }
601 
602     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
603         self.ctrl_pressed = event.ctrl;
604 
605         // Clear status message when typing/key press occurs
606         if event.state == ElementState::Pressed && self.status_message.is_some() {
607             self.status_message = None;
608             *needs_rebuild = true;
609             self.needs_rebuild = true;
610         }
611 
612         let mut handled = false;
613         let mut msg_out = None;
614 
615         // Custom keyboard shortcuts (input.kdl `cce-text-editor` domain);
616         // the font-size chords stay hardcoded (+/= don't round-trip chords).
617         if event.state == ElementState::Pressed {
618             let m = |chord: &str| cce_ui::widget::match_key_shortcut(event, chord);
619             if m(&self.keys.new_document) {
620                 msg_out = Some(AppMessage::NewDocument);
621                 handled = true;
622             } else if m(&self.keys.open_document) {
623                 msg_out = Some(AppMessage::OpenDocument);
624                 handled = true;
625             } else if m(&self.keys.save_document) {
626                 msg_out = Some(AppMessage::SaveDocument);
627                 handled = true;
628             } else if m(&self.keys.quit) {
629                 msg_out = Some(AppMessage::Exit);
630                 handled = true;
631             }
632         }
633         if !handled && event.ctrl && event.state == ElementState::Pressed {
634             if let Key::Character(ref ch) = event.logical_key {
635                 match ch.to_lowercase().as_str() {
636                     "=" | "+" => {
637                         self.ui_context[self.editor].font_size = (self.ui_context[self.editor].font_size + 1.0).min(72.0);
638                         *needs_rebuild = true;
639                         self.needs_rebuild = true;
640                         handled = true;
641                     }
642                     "-" | "_" => {
643                         self.ui_context[self.editor].font_size = (self.ui_context[self.editor].font_size - 1.0).max(6.0);
644                         *needs_rebuild = true;
645                         self.needs_rebuild = true;
646                         handled = true;
647                     }
648                     _ => {}
649                 }
650             }
651         }
652 
653         // Routed dispatch (Phase 6ab): the router delivers KeyInput to the ctx-focused
654         // widget first (the editor while it holds focus), then descends the root.
655         if !handled {
656             let ev = cce_ui::widget::Event::KeyInput(event.clone());
657             let menu = self.menu_dropdown.id();
658             let editor = self.editor.id();
659             if self.ui_context.propagate_event(&ev, menu) {
660                 handled = true;
661                 if self.ui_context[self.menu_dropdown].take_change() {
662                     msg_out = self.menu_action();
663                 }
664             } else if self.ui_context.propagate_event(&ev, editor) {
665                 handled = true;
666             }
667         }
668 
669         if handled {
670             *needs_rebuild = true;
671             self.needs_rebuild = true;
672         }
673 
674         msg_out
675     }
676 }
677 
678 impl TextEditorApp {
679     /// Map the File menu's selected option to its app command.
680     fn menu_action(&self) -> Option<AppMessage> {
681         match self.ui_context[self.menu_dropdown].options.get(self.ui_context[self.menu_dropdown].selected).map(String::as_str) {
682             Some("New") => Some(AppMessage::NewDocument),
683             Some("Open...") => Some(AppMessage::OpenDocument),
684             Some("Save") => Some(AppMessage::SaveDocument),
685             Some("Save As...") => Some(AppMessage::SaveDocumentAs),
686             Some("Exit") => Some(AppMessage::Exit),
687             _ => None,
688         }
689     }
690 }
691 
692 fn main() {
693     cce_ui::engine::run::<TextEditorApp>();
694 }