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

src/main.rs (27.6K)

  1 use wayland_client::QueueHandle;
  2 use cce_ui::cosmic_text::FontSystem;
  3 use cce_ui::engine::{Application, EngineState, 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: cce_ui::widget::Adapted<Dropdown>,
 49     
 50     // Editor TextBox
 51     editor: 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     widgets_registered: bool,
 69 }
 70 
 71 impl TextEditorApp {
 72     fn pick_file_to_open(&self) -> Option<std::path::PathBuf> {
 73         let output = std::process::Command::new("/home/lsgalante/.local/bin/cce-files")
 74             .arg("--select")
 75             .output()
 76             .or_else(|_| {
 77                 std::process::Command::new("cce-files")
 78                     .arg("--select")
 79                     .output()
 80             })
 81             .ok()?;
 82         
 83         if output.status.success() {
 84             let stdout = String::from_utf8_lossy(&output.stdout);
 85             let trimmed = stdout.trim();
 86             if !trimmed.is_empty() {
 87                 return Some(std::path::PathBuf::from(trimmed));
 88             }
 89         }
 90         None
 91     }
 92 
 93     fn perform_save_as(&mut self, needs_rebuild: &mut bool) {
 94         let path_opt = std::process::Command::new("/home/lsgalante/.local/bin/cce-files")
 95             .arg("--save")
 96             .output()
 97             .or_else(|_| {
 98                 std::process::Command::new("cce-files")
 99                     .arg("--save")
100                     .output()
101             })
102             .ok()
103             .and_then(|output| {
104                 if output.status.success() {
105                     let stdout = String::from_utf8_lossy(&output.stdout);
106                     let trimmed = stdout.trim();
107                     if !trimmed.is_empty() {
108                         Some(std::path::PathBuf::from(trimmed))
109                     } else {
110                         None
111                     }
112                 } else {
113                     None
114                 }
115             });
116 
117         if let Some(path) = path_opt {
118             let content = if self.editor.editing { &self.editor.edit_buffer } else { &self.editor.text };
119             match std::fs::write(&path, content) {
120                 Ok(_) => {
121                     self.current_file_path = Some(path.clone());
122                     if self.editor.editing {
123                         self.editor.text = 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.editor.editing {
146             self.editor.text != 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.editor.editing { &self.editor.edit_buffer } else { &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.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 new(_qh: &QueueHandle<EngineState<Self>>, _sender: calloop::channel::Sender<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         menu_dropdown.set_rect(cce_ui::layout::root_plate_inset(), (MENUBAR_H - 26.0) / 2.0, 70.0, 26.0);
227 
228         // Monospace textbox setup
229         let mut editor = TextBox::new(String::new())
230             .with_multiline(true)
231             .with_draw_bg_border(true)
232             .with_max_width(None);
233         editor.font_family = "monospace".to_string();
234         editor.font_size = 13.0;
235 
236         // Auto-open path if passed as argv[1]
237         let args: Vec<String> = std::env::args().collect();
238         let mut current_file_path = None;
239         if args.len() > 1 {
240             let path = std::path::PathBuf::from(&args[1]);
241             if path.exists() {
242                 if let Ok(content) = std::fs::read_to_string(&path) {
243                     editor.text = content;
244                     editor.edit_buffer = editor.text.clone();
245                     current_file_path = Some(path);
246                 }
247             }
248         }
249 
250         Self {
251             keys: EditorKeys::load(),
252             menu_dropdown,
253             editor,
254             current_file_path,
255             width: 800,
256             height: 600,
257             scale_factor: 1.0,
258             font_system: cce_ui::create_font_system_with_system_fonts(),
259             needs_rebuild: true,
260             ui_context: cce_ui::context::UiContext::new(),
261             ctrl_pressed: false,
262             initial_focus: true,
263             status_message: None,
264             widgets_registered: false,
265         }
266     }
267 
268     fn settings(&self) -> WindowSettings {
269         WindowSettings {
270             title: "Clear Text Editor".to_string(),
271             app_id: "cce-text-editor".to_string(),
272             width: 800,
273             height: 600,
274             fullscreen: false,
275             min_size: Some((500, 400)),
276         }
277     }
278 
279     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool) {
280         match msg {
281             AppMessage::Exit => {
282                 *exit = true;
283             }
284             AppMessage::NewDocument => {
285                 self.editor.text = String::new();
286                 self.editor.edit_buffer = String::new();
287                 self.editor.editing = false;
288                 self.editor.cursor_idx = 0;
289                 self.editor.select_anchor = None;
290                 self.current_file_path = None;
291                 self.status_message = None;
292 
293                 self.ui_context.set_focused(&mut self.editor);
294                 WidgetHost::focus(&mut self.editor);
295 
296                 *needs_rebuild = true;
297                 self.needs_rebuild = true;
298             }
299             AppMessage::OpenDocument => {
300                 if let Some(path) = self.pick_file_to_open() {
301                     match std::fs::read_to_string(&path) {
302                         Ok(content) => {
303                             self.editor.text = content;
304                             self.editor.edit_buffer = self.editor.text.clone();
305                             self.editor.cursor_idx = 0;
306                             self.editor.select_anchor = None;
307                             self.editor.editing = false;
308                             self.current_file_path = Some(path.clone());
309                             self.status_message = Some((format!("Opened {}", path.file_name().unwrap_or_default().to_string_lossy()), false));
310 
311                             self.ui_context.set_focused(&mut self.editor);
312                             WidgetHost::focus(&mut self.editor);
313                         }
314                         Err(e) => {
315                             self.status_message = Some((format!("Error opening file: {}", e), true));
316                         }
317                     }
318                     *needs_rebuild = true;
319                     self.needs_rebuild = true;
320                 }
321             }
322             AppMessage::SaveDocument => {
323                 if self.current_file_path.is_some() {
324                     let path = self.current_file_path.clone().unwrap();
325                     let content = if self.editor.editing { &self.editor.edit_buffer } else { &self.editor.text };
326                     match std::fs::write(&path, content) {
327                         Ok(_) => {
328                             if self.editor.editing {
329                                 self.editor.text = self.editor.edit_buffer.clone();
330                             }
331                             self.status_message = Some((format!("Saved successfully to {}", path.file_name().unwrap_or_default().to_string_lossy()), false));
332                         }
333                         Err(e) => {
334                             self.status_message = Some((format!("Error saving file: {}", e), true));
335                         }
336                     }
337                     *needs_rebuild = true;
338                     self.needs_rebuild = true;
339                 } else {
340                     self.perform_save_as(needs_rebuild);
341                 }
342             }
343             AppMessage::SaveDocumentAs => {
344                 self.perform_save_as(needs_rebuild);
345             }
346         }
347     }
348 
349     fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
350 
351     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
352         // Phase 6 single paint path: the whole frame — chrome geometry, chrome text, and the
353         // two top-level widgets (menu_dropdown, editor) walked into the list — is built here.
354         // Widget text comes from the paint walk (Adapted::paint_self serves per-widget fonts).
355         if !self.widgets_registered {
356             self.widgets_registered = true;
357             let self_ptr = self as *mut Self;
358             unsafe {
359                 self.ui_context.register_widget(self.menu_dropdown.base().id(), (*self_ptr).menu_dropdown.as_ptr_mut());
360                 self.ui_context.register_widget(self.editor.base().id(), (*self_ptr).editor.as_ptr_mut());
361             }
362         }
363 
364         if self.initial_focus {
365             self.initial_focus = false;
366             self.ui_context.set_focused(&mut self.editor);
367             WidgetHost::focus(&mut self.editor);
368             self.needs_rebuild = true;
369         }
370         let size_changed = self.width != size.width as u32 || self.height != size.height as u32 || self.scale_factor != scale;
371         if self.needs_rebuild || size_changed {
372             self.width = size.width as u32;
373             self.height = size.height as u32;
374             self.scale_factor = scale;
375 
376             // Layout via the scene solver (Phase 6ab — the routed-events/scene-layout
377             // reference): the frame is a stretched column [menubar band (fixed
378             // MENUBAR_H, holding the fixed menu leaf), content (grow, holding the
379             // editor), status band (fixed STATUSBAR_H)].
380             //
381             // Spacing by rung, never by number. The root column itself carries no
382             // inset: both bands are flush to the window edge by design (the
383             // MenuBar/StatusBar band idiom), so the root rung is applied to what
384             // stands between and inside them instead — the menu and the editor
385             // inset from the window's sides by `root_plate_inset`, and the editor
386             // stands off each band by `root_plate_gap`, the gap between siblings
387             // on the root plate. The menu centres in its band rather than carrying
388             // a vertical padding.
389             {
390                 use cce_ui::scene::arena::Arena;
391                 use cce_ui::scene::layout::{
392                     compute_layout, CrossAlign, Edges, LayoutBox, Length, Size as LSize, Style,
393                 };
394                 let inset = cce_ui::layout::root_plate_inset();
395                 let gap = cce_ui::layout::root_plate_gap();
396                 let mut arena: Arena<LayoutBox> = Arena::new();
397                 let root = arena.insert(LayoutBox::container(
398                     Style::column().cross_align(CrossAlign::Stretch),
399                 ));
400                 let top_bar = arena.insert(LayoutBox::container({
401                     let mut s = Style::row()
402                         .height(Length::Fixed(MENUBAR_H))
403                         .gap(gap)
404                         .cross_align(CrossAlign::Center);
405                     s.padding = Edges { left: inset, right: inset, top: 0.0, bottom: 0.0 };
406                     s
407                 }));
408                 let menu = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(70.0, 26.0)));
409                 let content = arena.insert(LayoutBox::container({
410                     let mut s = Style::column().grow(1.0).cross_align(CrossAlign::Stretch);
411                     s.padding = Edges { left: inset, right: inset, top: gap, bottom: gap };
412                     s
413                 }));
414                 let editor = arena.insert(LayoutBox::container({
415                     let mut s = Style::column().grow(1.0);
416                     s.min_width = 100.0;
417                     s.min_height = 100.0;
418                     s
419                 }));
420                 let status = arena.insert(LayoutBox::container(
421                     Style::column().height(Length::Fixed(STATUSBAR_H)),
422                 ));
423                 arena.append_child(root, top_bar);
424                 arena.append_child(top_bar, menu);
425                 arena.append_child(root, content);
426                 arena.append_child(content, editor);
427                 arena.append_child(root, status);
428                 compute_layout(&mut arena, root, LSize::new(self.width as f32, self.height as f32));
429                 let m = arena.value(menu).unwrap().rect;
430                 self.menu_dropdown.set_rect(m.x, m.y, m.width, m.height);
431                 let e = arena.value(editor).unwrap().rect;
432                 self.editor.set_rect(e.x, e.y, e.width, e.height);
433             }
434 
435             // Glyph-advance shaping — load-bearing for cursor↔pixel mapping.
436             self.editor.prepare_text(&mut self.font_system);
437             self.needs_rebuild = false;
438 
439             self.ui_context.rebuild_spatial_grid();
440         }
441 
442         // Popover registration — ui_context ONLY (drives the engine's dl-text occlusion
443         // clamp). The popover itself draws into this display list below; the global
444         // registry fed the engine's render-only xdg popup, which this app no longer uses.
445         self.ui_context.clear_popovers();
446         if self.menu_dropdown.popover_rect().is_some() {
447             self.ui_context.register_popover(&mut self.menu_dropdown);
448         }
449 
450         use cce_ui::scene::layout::Rect;
451         let mut pc = cce_ui::scene::paint::PaintCtx::new();
452         let w = self.width as f32;
453         let h = self.height as f32;
454         let status_y = h - STATUSBAR_H;
455         // The standard root plate (cce-ui PlateSpec::window).
456         pc.root_plate(w, h);
457         // Silhouette radius (cce-ui RFC 7b): matches the compositor clip.
458         // TODO(style): these bands are flat fills over the plate; the toolkit's
459         // idiom for a menubar/status band is a carve (`recess_edges` with
460         // `bar_wall_width`, one wall facing the content, as cce-ui's demo does).
461         // Switching drops the bands' own fill colour, so it is a look change,
462         // not a spacing one, and is left for a deliberate pass.
463         let radius = cce_ui::layout::window_silhouette_radius();
464         if radius > 0.1 {
465             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]);
466             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]);
467         } else {
468             pc.quad(Rect { x: 0.0, y: 0.0, width: w, height: MENUBAR_H }, [0.08, 0.08, 0.12, 1.0]);
469             pc.quad(Rect { x: 0.0, y: status_y, width: w, height: STATUSBAR_H }, [0.08, 0.08, 0.10, 1.0]);
470         }
471         pc.quad(Rect { x: 0.0, y: MENUBAR_H, width: w, height: 1.0 }, [0.18, 0.18, 0.22, 1.0]);
472         pc.quad(Rect { x: 0.0, y: status_y, width: w, height: 1.0 }, [0.18, 0.18, 0.22, 1.0]);
473 
474         self.push_chrome_text(&mut pc);
475 
476         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.menu_dropdown, &mut pc);
477         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.editor, &mut pc);
478 
479         // The menu popover — geometry and labels last, on top of everything, exactly where
480         // it hit-tests (the engine xdg popup is gone). Labels carry bounds equal to the
481         // popover rect: clips them to the plate and exempts them from the occlusion clamp
482         // (the is-overlay-text convention).
483         if self.menu_dropdown.popover_rect().is_some() {
484             // PaintCtx is a RenderTarget: the popover draws its real prims (the
485             // dropdown's expanded inset-plate surface) with its own bounds.
486             self.menu_dropdown.render_popover(&mut pc);
487         }
488         Some(pc.finish())
489     }
490 
491     fn display_list_text(&self) -> bool {
492         true
493     }
494 
495     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
496         let mut changed = false;
497         let px = pos.x as f32;
498         let py = pos.y as f32;
499 
500         // Routed dispatch (Phase 6ab): one Event through the UiContext router per root;
501         // PointerMove visits both (hover bookkeeping + the router's drag forwarding).
502         let ev = cce_ui::widget::Event::PointerMove { x: px, y: py, local_x: px, local_y: py };
503         let menu = self.menu_dropdown.id();
504         let editor = self.editor.id();
505         if self.ui_context.propagate_event(&ev, menu) { changed = true; }
506         if self.ui_context.propagate_event(&ev, editor) { changed = true; }
507 
508         if changed {
509             *needs_rebuild = true;
510             self.needs_rebuild = true;
511         }
512     }
513 
514     fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message> {
515         let mut changed = false;
516         let mut msg_out = None;
517         let px = pos.x as f32;
518         let py = pos.y as f32;
519 
520         // Routed dispatch (Phase 6ab): the router hit-gates presses, synthesizes
521         // Enter/Leave, and records drag targets; the app keeps only take_change plumbing.
522         let ev = cce_ui::widget::Event::MouseButton { button, state, x: px, y: py, local_x: px, local_y: py };
523         let menu = self.menu_dropdown.id();
524         let editor = self.editor.id();
525         if self.ui_context.propagate_event(&ev, menu) {
526             changed = true;
527             if self.menu_dropdown.take_change() {
528                 msg_out = self.menu_action();
529             }
530         } else if self.ui_context.propagate_event(&ev, editor) {
531             changed = true;
532         } else if state == ElementState::Pressed && button == MouseButton::Left {
533             self.editor.unfocus();
534             changed = true;
535         }
536 
537         if changed || msg_out.is_some() {
538             *needs_rebuild = true;
539             self.needs_rebuild = true;
540         }
541 
542         msg_out
543     }
544 
545     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
546         if self.ctrl_pressed {
547             match delta {
548                 MouseScrollDelta::LineDelta(_, y) => {
549                     if *y > 0.0 {
550                         self.editor.font_size = (self.editor.font_size + 1.0).min(72.0);
551                     } else if *y < 0.0 {
552                         self.editor.font_size = (self.editor.font_size - 1.0).max(6.0);
553                     }
554                     *needs_rebuild = true;
555                     self.needs_rebuild = true;
556                 }
557                 MouseScrollDelta::PixelDelta(pos) => {
558                     if pos.y > 0.0 {
559                         self.editor.font_size = (self.editor.font_size + 1.0).min(72.0);
560                     } else if pos.y < 0.0 {
561                         self.editor.font_size = (self.editor.font_size - 1.0).max(6.0);
562                     }
563                     *needs_rebuild = true;
564                     self.needs_rebuild = true;
565                 }
566             }
567         } else {
568             // Plain wheel: routed to the editor, whose TextBox owns the
569             // document scroll (glide and coast included — its tick runs
570             // through the runner's ui_context tick). Nothing forwarded it
571             // before, so the wheel over the document was dead.
572             let px = pos.x as f32;
573             let py = pos.y as f32;
574             let ev = cce_ui::widget::Event::MouseWheel { delta: *delta, x: px, y: py, local_x: px, local_y: py };
575             let editor = self.editor.id();
576             if self.ui_context.propagate_event(&ev, editor) {
577                 *needs_rebuild = true;
578                 self.needs_rebuild = true;
579             }
580         }
581     }
582 
583     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
584         self.ctrl_pressed = event.ctrl;
585 
586         // Clear status message when typing/key press occurs
587         if event.state == ElementState::Pressed && self.status_message.is_some() {
588             self.status_message = None;
589             *needs_rebuild = true;
590             self.needs_rebuild = true;
591         }
592 
593         let mut handled = false;
594         let mut msg_out = None;
595 
596         // Custom keyboard shortcuts (input.kdl `cce-text-editor` domain);
597         // the font-size chords stay hardcoded (+/= don't round-trip chords).
598         if event.state == ElementState::Pressed {
599             let m = |chord: &str| cce_ui::widget::match_key_shortcut(event, chord);
600             if m(&self.keys.new_document) {
601                 msg_out = Some(AppMessage::NewDocument);
602                 handled = true;
603             } else if m(&self.keys.open_document) {
604                 msg_out = Some(AppMessage::OpenDocument);
605                 handled = true;
606             } else if m(&self.keys.save_document) {
607                 msg_out = Some(AppMessage::SaveDocument);
608                 handled = true;
609             } else if m(&self.keys.quit) {
610                 msg_out = Some(AppMessage::Exit);
611                 handled = true;
612             }
613         }
614         if !handled && event.ctrl && event.state == ElementState::Pressed {
615             if let Key::Character(ref ch) = event.logical_key {
616                 match ch.to_lowercase().as_str() {
617                     "=" | "+" => {
618                         self.editor.font_size = (self.editor.font_size + 1.0).min(72.0);
619                         *needs_rebuild = true;
620                         self.needs_rebuild = true;
621                         handled = true;
622                     }
623                     "-" | "_" => {
624                         self.editor.font_size = (self.editor.font_size - 1.0).max(6.0);
625                         *needs_rebuild = true;
626                         self.needs_rebuild = true;
627                         handled = true;
628                     }
629                     _ => {}
630                 }
631             }
632         }
633 
634         // Routed dispatch (Phase 6ab): the router delivers KeyInput to the ctx-focused
635         // widget first (the editor while it holds focus), then descends the root.
636         if !handled {
637             let ev = cce_ui::widget::Event::KeyInput(event.clone());
638             let menu = self.menu_dropdown.id();
639             let editor = self.editor.id();
640             if self.ui_context.propagate_event(&ev, menu) {
641                 handled = true;
642                 if self.menu_dropdown.take_change() {
643                     msg_out = self.menu_action();
644                 }
645             } else if self.ui_context.propagate_event(&ev, editor) {
646                 handled = true;
647             }
648         }
649 
650         if handled {
651             *needs_rebuild = true;
652             self.needs_rebuild = true;
653         }
654 
655         msg_out
656     }
657 }
658 
659 impl TextEditorApp {
660     /// Map the File menu's selected option to its app command.
661     fn menu_action(&self) -> Option<AppMessage> {
662         match self.menu_dropdown.options.get(self.menu_dropdown.selected).map(String::as_str) {
663             Some("New") => Some(AppMessage::NewDocument),
664             Some("Open...") => Some(AppMessage::OpenDocument),
665             Some("Save") => Some(AppMessage::SaveDocument),
666             Some("Save As...") => Some(AppMessage::SaveDocumentAs),
667             Some("Exit") => Some(AppMessage::Exit),
668             _ => None,
669         }
670     }
671 }
672 
673 fn main() {
674     let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
675     let _guard = rt.enter();
676     
677     cce_ui::engine::run::<TextEditorApp>();
678 }