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 }