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 }