Skip to main content

atmos/apps/note/
mod.rs

1//! マルチタブテキストエディタアプリ。
2//!
3//! [`NoteTab`] ごとに独立したファイル内容を保持し、複数ファイルをタブで切替編集できる。
4//! `.aura` スクリプトの編集・実行結果表示(出力ペイン)も兼ねる。
5// note.rs - Note Multi-Tab Text Editor Application for AtmOS
6#![allow(dead_code)]
7
8use crate::kernel::draw::{Color, Screen};
9use crate::os_lib::web_engine::WebEngine;
10use alloc::string::String;
11use alloc::vec::Vec;
12
13const MENU_HEIGHT: u32 = 24;
14
15/// 1つのノートタブが持つ状態
16struct NoteTab {
17    filename: String,
18    content: String,
19    dirty: bool,
20}
21
22impl NoteTab {
23    fn new_untitled() -> Self {
24        Self {
25            filename: String::from("Untitled"),
26            content: String::new(),
27            dirty: false,
28        }
29    }
30
31    fn title(&self) -> String {
32        if self.dirty {
33            alloc::format!("* {}", self.filename)
34        } else {
35            self.filename.clone()
36        }
37    }
38}
39
40pub struct NoteApp {
41    tabs_data: Vec<NoteTab>,
42    active_idx: usize,
43    pub engine: WebEngine,
44    pub last_win_w: u32,
45    pub last_win_h: u32,
46    pub show_menu: bool,
47    pub open_menu: Option<u8>, // 0:File, 1:Edit, 2:Run, 3:Format, 4:View, 5:Help
48    pub last_btn: bool,
49    ctrl_held: bool,
50    alt_held: bool,
51
52    // スライドパネル(Open/Save)
53    pub panel_slide: i32,
54    pub panel_mode: u8, // 0: None, 1: Open, 2: Save
55    pub panel_files: Vec<String>,
56    pub panel_input: String,
57    pub panel_selected: Option<usize>,
58
59    // 実行結果ペインの状態
60    pub output_panel_open: bool,
61    pub output_text: String,
62    pub output_panel_height: u32,
63}
64
65impl Default for NoteApp {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl NoteApp {
72    /// 外部からファイルを指定して開くためのヘルパー(FilesApp・Aura から呼び出し)
73    pub fn open_file(filename: &str, content: &str) -> Self {
74        let mut app = Self::new();
75        if let Some(tab) = app.tabs_data.get_mut(0) {
76            tab.filename = String::from(filename);
77            tab.content = String::from(content);
78            tab.dirty = false;
79        }
80        app.engine
81            .form_values
82            .insert(String::from("editor"), String::from(content));
83        app.engine
84            .form_values
85            .insert(String::from("filename"), String::from(filename));
86        app.engine.dirty = true;
87        app
88    }
89
90    pub fn new() -> Self {
91        let mut engine = WebEngine::new();
92        engine
93            .form_values
94            .insert(String::from("filename"), String::from("Untitled"));
95        // html{margin:0} でルート余白を消し、textarea の 100%/100% が
96        // ビューポートヒント (= エディタ領域全体) にフィットする
97        engine.parse_and_layout(
98            "<style>html { margin: 0; }</style><textarea id=\"editor\" autofocus style=\"width: 100%; height: 100%; margin: 0; border: none;\"></textarea>",
99            None,
100            0, 600, 450,
101        );
102        Self {
103            tabs_data: alloc::vec![NoteTab::new_untitled()],
104            active_idx: 0,
105            engine,
106            last_win_w: 600,
107            last_win_h: 450,
108            show_menu: false,
109            open_menu: None,
110            last_btn: false,
111            ctrl_held: false,
112            alt_held: false,
113            panel_slide: 0,
114            panel_mode: 0,
115            panel_files: Vec::new(),
116            panel_input: String::new(),
117            panel_selected: None,
118            output_panel_open: false,
119            output_text: String::new(),
120            output_panel_height: 120,
121        }
122    }
123
124    // エンジンの内容を現在のタブに同期する
125    fn flush_current_tab(&mut self) {
126        if let Some(content) = self.engine.form_values.get("editor") {
127            let content = content.clone();
128            if let Some(tab) = self.tabs_data.get_mut(self.active_idx) {
129                if tab.content != content {
130                    tab.content = content;
131                    tab.dirty = true;
132                }
133            }
134        }
135    }
136
137    // タブのコンテンツをエンジンに反映する
138    fn load_tab_into_engine(&mut self, idx: usize) {
139        if idx >= self.tabs_data.len() {
140            return;
141        }
142        let content = self.tabs_data[idx].content.clone();
143        let filename = self.tabs_data[idx].filename.clone();
144        self.engine
145            .form_values
146            .insert(String::from("editor"), content);
147        self.engine
148            .form_values
149            .insert(String::from("filename"), filename);
150        self.engine.dirty = true;
151    }
152
153    fn open_panel(&mut self, mode: u8) {
154        self.flush_current_tab();
155        self.panel_mode = mode;
156        if let Some(tab) = self.tabs_data.get(self.active_idx) {
157            self.panel_input = tab.filename.clone();
158        }
159        self.panel_selected = None;
160        self.panel_files.clear();
161        let fs = crate::kernel::fs::get_fs();
162        for meta in fs.list_files() {
163            self.panel_files.push(String::from(meta.get_filename()));
164        }
165    }
166
167    fn save_current(&mut self) {
168        self.flush_current_tab();
169        let (filename, content) = if let Some(tab) = self.tabs_data.get_mut(self.active_idx) {
170            (tab.filename.clone(), tab.content.clone())
171        } else {
172            return;
173        };
174
175        let fs = crate::kernel::fs::get_fs();
176        if fs.save_file(&filename, content.as_bytes(), "note").is_ok() {
177            crate::info!("[FS] Note: Saved to {}", filename);
178            if let Some(tab) = self.tabs_data.get_mut(self.active_idx) {
179                tab.dirty = false;
180            }
181            self.engine
182                .form_values
183                .insert(String::from("filename"), filename.clone());
184            crate::kernel::window_mgr::get_instance().dirty = true;
185        } else {
186            crate::error!("[FS] Note: Failed to save {}", filename);
187        }
188    }
189
190    fn load_file_into_current(&mut self) {
191        let filename = if let Some(tab) = self.tabs_data.get(self.active_idx) {
192            tab.filename.clone()
193        } else {
194            return;
195        };
196
197        let fs = crate::kernel::fs::get_fs();
198        if let Some((_, content_bytes)) = fs.read_file(&filename) {
199            if let Ok(content_str) = core::str::from_utf8(&content_bytes) {
200                if let Some(tab) = self.tabs_data.get_mut(self.active_idx) {
201                    tab.content = String::from(content_str);
202                    tab.dirty = false;
203                }
204                self.load_tab_into_engine(self.active_idx);
205                crate::info!("[FS] Note: Loaded from {}", filename);
206                crate::kernel::window_mgr::get_instance().dirty = true;
207            }
208        }
209    }
210
211    fn new_tab_action(&mut self) {
212        self.flush_current_tab();
213        self.tabs_data.push(NoteTab::new_untitled());
214        let new_idx = self.tabs_data.len() - 1;
215        self.active_idx = new_idx;
216        self.load_tab_into_engine(new_idx);
217        // WM側にタブ追加を通知しない(tabs()の戻り値が変わるので次のdraw_allで反映)
218        crate::kernel::window_mgr::get_instance().dirty = true;
219    }
220
221    /// Aura をサンドボックス内で実行する
222    fn run_script(&mut self) {
223        self.flush_current_tab();
224        let content = self
225            .engine
226            .form_values
227            .get("editor")
228            .cloned()
229            .unwrap_or_default();
230
231        self.output_text = String::from("Running script...\n");
232        self.output_panel_open = true;
233        crate::kernel::window_mgr::get_instance().dirty = true;
234
235        // グローバル環境をキーボードなしでセットアップ
236        let mut run_env = crate::os_lib::aura::eval::setup_global_env(core::ptr::null_mut());
237
238        // サンドボックスを上書き設定(権限が空、名前を "note_sandbox" に設定)
239        let manifest = crate::kernel::app_sandbox::AppManifest {
240            name: String::from("note_sandbox"),
241            permissions: alloc::vec![], // 権限なし (fs_user, fs_global もなし)
242            ..Default::default()
243        };
244
245        let mut sandbox = crate::kernel::app_sandbox::AppSandbox::new(0, manifest);
246        sandbox.data_dir = String::from("/apps/note/sandbox/");
247        run_env.sandbox = Some(sandbox);
248
249        // カレントディレクトリを制限フォルダにする
250        run_env.set(
251            alloc::string::String::from("cwd"),
252            crate::os_lib::aura::eval::Value::Str(String::from("/apps/note/sandbox/")),
253        );
254
255        // 実行して出力を受ける
256        let result = crate::os_lib::aura::eval_string(&content, &mut run_env);
257
258        if result.is_empty() {
259            self.output_text = String::from("Execution finished (no output).");
260        } else {
261            self.output_text = result;
262        }
263    }
264}
265
266impl crate::kernel::window_mgr::App for NoteApp {
267    fn name(&self) -> &str {
268        "note"
269    }
270
271    fn instance_info(&self) -> String {
272        if let Some(tab) = self.tabs_data.get(self.active_idx) {
273            tab.filename.clone()
274        } else {
275            String::from("Note")
276        }
277    }
278
279    fn draw(&mut self, screen: &Screen, win_x: u32, win_y: u32, win_w: u32, win_h: u32) {
280        let config = crate::kernel::config::get_config();
281        let bg_color = Color(config.theme.active_window_border);
282        let text_color = Color(config.theme.window_title_fg);
283        let highlight = Color(config.theme.focus_highlight);
284
285        // リサイズ・表示状態変化検出
286        let target_editor_h = if self.output_panel_open {
287            win_h
288                .saturating_sub(MENU_HEIGHT)
289                .saturating_sub(self.output_panel_height)
290        } else {
291            win_h.saturating_sub(MENU_HEIGHT)
292        };
293
294        if self.last_win_w != win_w
295            || self.last_win_h != win_h
296            || self.engine.win_h != target_editor_h
297        {
298            self.last_win_w = win_w;
299            self.last_win_h = win_h;
300            self.engine.win_w = win_w;
301            self.engine.win_h = target_editor_h;
302            let html = self.engine.last_html.clone();
303            self.engine
304                .parse_and_layout(&html, None, 0, self.engine.win_w, self.engine.win_h);
305        }
306
307        // エンジン内の変更をタブに同期(描画前)
308        if let Some(content) = self.engine.form_values.get("editor") {
309            let content = content.clone();
310            if let Some(tab) = self.tabs_data.get_mut(self.active_idx) {
311                if !tab.dirty && tab.content != content {
312                    tab.dirty = true;
313                    crate::kernel::window_mgr::get_instance().dirty = true;
314                }
315                tab.content = content;
316            }
317        }
318
319        // 1. ネイティブメニューバー
320        screen.boxfill(
321            win_x,
322            win_y,
323            win_x + win_w - 1,
324            win_y + MENU_HEIGHT - 1,
325            bg_color,
326        );
327        let menus = ["File", "Edit", "Run", "Format", "View", "Help"];
328        for (i, title) in menus.iter().enumerate() {
329            let mx = win_x + 10 + (i as u32 * 55);
330            if Some(i as u8) == self.open_menu {
331                screen.boxfill(mx - 5, win_y, mx + 45, win_y + MENU_HEIGHT - 1, highlight);
332            }
333            screen.draw_string_vector(mx, win_y + 4, title, text_color, 14);
334        }
335
336        // 2. テキストエリア本体
337        if win_h > MENU_HEIGHT {
338            let editor_h = if self.output_panel_open {
339                win_h
340                    .saturating_sub(MENU_HEIGHT)
341                    .saturating_sub(self.output_panel_height)
342            } else {
343                win_h.saturating_sub(MENU_HEIGHT)
344            };
345            if editor_h > 0 {
346                self.engine
347                    .draw(screen, win_x, win_y + MENU_HEIGHT, win_w, editor_h, 0);
348            }
349        }
350
351        // 3. ドロップダウンメニュー
352        if let Some(menu_idx) = self.open_menu {
353            let mx = win_x + 10 + (menu_idx as u32 * 55) - 5;
354            let my = win_y + MENU_HEIGHT;
355            let mw = 200u32;
356            let items: &[(&str, &str)] = match menu_idx {
357                0 => &[
358                    ("New Tab", "Alt+T"),
359                    ("Open...", "Alt+O"),
360                    ("Save", "Alt+S"),
361                    ("Save As...", ""),
362                ],
363                1 => &[("Undo", ""), ("Cut", ""), ("Copy", ""), ("Paste", "")],
364                2 => &[], // Run はクリック時に即実行
365                3 => &[(
366                    if self.engine.editor_wrap {
367                        "Word Wrap: ON"
368                    } else {
369                        "Word Wrap: OFF"
370                    },
371                    "",
372                )],
373                4 => &[
374                    ("Zoom In", ""),
375                    ("Zoom Out", ""),
376                    ("Toggle Output", "Alt+P"),
377                ],
378                5 => &[("About AtmNote", "")],
379                _ => &[],
380            };
381
382            let mh = (items.len() as u32) * 24 + 4;
383            if mh > 4 {
384                screen.boxfill(mx + 2, my + 2, mx + mw + 2, my + mh + 2, Color(0x80000000));
385                screen.boxfill(mx, my, mx + mw, my + mh, bg_color);
386
387                for (j, (label, shortcut)) in items.iter().enumerate() {
388                    let iy = my + 4 + (j as u32 * 24);
389                    screen.draw_string_vector(mx + 10, iy, label, text_color, 14);
390                    if !shortcut.is_empty() {
391                        screen.draw_string_vector(
392                            mx + mw - 60,
393                            iy,
394                            shortcut,
395                            Color(config.theme.app_fg),
396                            12,
397                        );
398                    }
399                }
400            }
401        }
402
403        // 4. スライドパネル(Open/Save)
404        if self.panel_mode > 0 {
405            if self.panel_slide < 100 {
406                self.panel_slide += 10;
407                if self.panel_slide > 100 {
408                    self.panel_slide = 100;
409                }
410                crate::kernel::window_mgr::get_instance().dirty = true;
411            }
412        } else if self.panel_slide > 0 {
413            self.panel_slide -= 10;
414            if self.panel_slide < 0 {
415                self.panel_slide = 0;
416            }
417            crate::kernel::window_mgr::get_instance().dirty = true;
418        }
419
420        if self.panel_slide > 0 {
421            let panel_w = 300u32;
422            let current_w = (panel_w * self.panel_slide as u32) / 100;
423            let px = win_x + win_w - current_w;
424            let py = win_y + MENU_HEIGHT;
425            let ph = win_h.saturating_sub(MENU_HEIGHT);
426
427            screen.boxfill(px, py, win_x + win_w, py + ph, Color(config.theme.app_bg));
428            screen.boxfill(px.saturating_sub(2), py, px, py + ph, Color(0x80000000));
429
430            if self.panel_slide >= 100 {
431                let title = if self.panel_mode == 1 {
432                    "Open File"
433                } else {
434                    "Save As"
435                };
436                screen.draw_string_vector(px + 10, py + 10, title, Color(config.theme.app_fg), 18);
437
438                let list_y = py + 40;
439                let list_h = ph.saturating_sub(120);
440                screen.boxfill(
441                    px + 10,
442                    list_y,
443                    px + panel_w - 10,
444                    list_y + list_h,
445                    Color(config.theme.terminal_bg),
446                );
447                for (i, file) in self.panel_files.iter().take(12).enumerate() {
448                    let item_y = list_y + (i as u32) * 22;
449                    if Some(i) == self.panel_selected {
450                        screen.boxfill(
451                            px + 10,
452                            item_y,
453                            px + panel_w - 10,
454                            item_y + 22,
455                            Color(config.theme.focus_highlight),
456                        );
457                    }
458                    screen.draw_string_vector(
459                        px + 15,
460                        item_y + 3,
461                        file,
462                        Color(config.theme.app_fg),
463                        13,
464                    );
465                }
466
467                let input_y = py + ph.saturating_sub(70);
468                screen.boxfill(
469                    px + 10,
470                    input_y,
471                    px + panel_w - 10,
472                    input_y + 24,
473                    Color(config.theme.terminal_bg),
474                );
475                screen.draw_string_vector(
476                    px + 15,
477                    input_y + 4,
478                    &self.panel_input,
479                    Color(config.theme.app_fg),
480                    14,
481                );
482                // カーソル
483                let cx = px + 15 + (self.panel_input.len() as u32 * 8);
484                screen.boxfill(
485                    cx,
486                    input_y + 4,
487                    cx + 1,
488                    input_y + 18,
489                    Color(config.theme.app_fg),
490                );
491
492                let btn_y = py + ph.saturating_sub(35);
493                screen.boxfill(
494                    px + 10,
495                    btn_y,
496                    px + 80,
497                    btn_y + 24,
498                    Color(config.theme.focus_highlight),
499                );
500                screen.draw_string_vector(px + 20, btn_y + 4, "Cancel", text_color, 14);
501                screen.boxfill(
502                    px + 100,
503                    btn_y,
504                    px + 180,
505                    btn_y + 24,
506                    Color(config.theme.button_active_bg),
507                );
508                let action_label = if self.panel_mode == 1 { "Open" } else { "Save" };
509                screen.draw_string_vector(px + 120, btn_y + 4, action_label, text_color, 14);
510            }
511        }
512
513        // 5. 出力ペイン (Execution Output)
514        if self.output_panel_open {
515            let py = win_y + win_h - self.output_panel_height;
516            let ph = self.output_panel_height;
517            let px = win_x;
518            let pw = win_w;
519
520            // 背景塗りつぶし
521            screen.boxfill(px, py, px + pw - 1, py + ph - 1, Color(config.theme.ui_overlay_bg));
522            // 上部境界線
523            screen.boxfill(
524                px,
525                py,
526                px + pw - 1,
527                py + 1,
528                Color(config.theme.active_window_border),
529            );
530
531            // タイトルバー
532            let title_bg = Color(config.theme.active_window_border);
533            let title_h = 20u32;
534            screen.boxfill(px, py + 2, px + pw - 1, py + title_h - 1, title_bg);
535            screen.draw_string_vector(
536                px + 10,
537                py + 4,
538                "Execution Output",
539                Color(config.theme.window_title_fg),
540                12,
541            );
542
543            // [X] 閉じるボタン
544            let close_btn_w = 20u32;
545            let bx1 = px + pw - close_btn_w - 5;
546            let bx2 = px + pw - 5;
547            screen.boxfill(bx1, py + 3, bx2, py + title_h - 3, Color(config.theme.win_btn_close));
548            screen.draw_string_vector(bx1 + 6, py + 4, "X", Color(config.theme.browser_fg), 11);
549
550            // テキスト表示領域
551            let text_y_start = py + title_h + 5;
552            let text_h_limit = ph.saturating_sub(title_h).saturating_sub(10);
553            let line_height = 16u32;
554            let max_display_lines = (text_h_limit / line_height) as usize;
555
556            let lines: Vec<&str> = self.output_text.split('\n').collect();
557            let start_line = if lines.len() > max_display_lines {
558                lines.len() - max_display_lines
559            } else {
560                0
561            };
562
563            for (i, line) in lines[start_line..].iter().enumerate() {
564                let ly = text_y_start + (i as u32 * line_height);
565                let text_color = if line.starts_with("Eval Error:")
566                    || line.starts_with("Syntax Error:")
567                    || line.starts_with("Error:")
568                {
569                    Color(config.theme.error_fg)
570                } else {
571                    Color(config.theme.browser_fg)
572                };
573                screen.draw_string_vector(px + 15, ly, line, text_color, 12);
574            }
575        }
576    }
577
578    fn on_mouse(
579        &mut self,
580        local_x: i32,
581        local_y: i32,
582        btn_left: bool,
583        btn_right: bool,
584        wheel: i32,
585    ) {
586        let just_pressed = btn_left && !self.last_btn;
587        self.last_btn = btn_left;
588
589        // パネルが開いている場合
590        if self.panel_mode > 0 && self.panel_slide >= 100 {
591            if just_pressed {
592                let panel_w = 300i32;
593                let px = self.last_win_w as i32 - panel_w;
594                if local_x >= px {
595                    let py = MENU_HEIGHT as i32;
596                    let ph = self.last_win_h as i32 - MENU_HEIGHT as i32;
597                    let list_y = py + 40;
598                    let list_h = ph - 120;
599
600                    if local_y >= list_y && local_y <= list_y + list_h {
601                        let idx = (local_y - list_y) / 22;
602                        if idx >= 0 && (idx as usize) < self.panel_files.len() {
603                            self.panel_selected = Some(idx as usize);
604                            self.panel_input = self.panel_files[idx as usize].clone();
605                            crate::kernel::window_mgr::get_instance().dirty = true;
606                        }
607                    }
608
609                    let btn_y = py + ph - 35;
610                    if local_y >= btn_y && local_y <= btn_y + 24 {
611                        if local_x >= px + 10 && local_x <= px + 80 {
612                            self.panel_mode = 0;
613                        } else if local_x >= px + 100
614                            && local_x <= px + 180
615                            && !self.panel_input.is_empty()
616                        {
617                            if let Some(tab) = self.tabs_data.get_mut(self.active_idx) {
618                                tab.filename = self.panel_input.clone();
619                            }
620                            if self.panel_mode == 1 {
621                                self.load_file_into_current();
622                            } else {
623                                self.save_current();
624                            }
625                            self.panel_mode = 0;
626                        }
627                    }
628                } else {
629                    self.panel_mode = 0;
630                }
631            }
632            return;
633        }
634
635        // 下ペインの [X] 閉じるボタンのクリック判定
636        if self.output_panel_open {
637            let py = self.last_win_h as i32 - self.output_panel_height as i32;
638            let close_btn_w = 20i32;
639            let bx1 = self.last_win_w as i32 - close_btn_w - 5;
640            let bx2 = self.last_win_w as i32 - 5;
641            if just_pressed
642                && local_y >= py + 3
643                && local_y <= py + 17
644                && local_x >= bx1
645                && local_x <= bx2
646            {
647                self.output_panel_open = false;
648                crate::kernel::window_mgr::get_instance().dirty = true;
649                return;
650            }
651        }
652
653        if just_pressed {
654            // ドロップダウンが開いている場合の処理
655            if let Some(menu_idx) = self.open_menu {
656                let mx = 10 + (menu_idx as i32 * 55) - 5;
657                let my = MENU_HEIGHT as i32;
658                let mw = 200;
659                let items_len: i32 = match menu_idx {
660                    0 => 4,
661                    1 => 4,
662                    2 => 0,
663                    3 => 1,
664                    4 => 3,
665                    5 => 1,
666                    _ => 0,
667                };
668                let mh = items_len * 24 + 4;
669
670                if items_len > 0
671                    && local_x >= mx
672                    && local_x <= mx + mw
673                    && local_y >= my
674                    && local_y <= my + mh
675                {
676                    let item_idx = (local_y - my - 2) / 24;
677                    if menu_idx == 0 {
678                        // File
679                        match item_idx {
680                            0 => self.new_tab_action(), // New Tab
681                            1 => self.open_panel(1),    // Open...
682                            2 => self.save_current(),   // Save
683                            3 => self.open_panel(2),    // Save As...
684                            _ => {}
685                        }
686                    } else if menu_idx == 3 {
687                        // Format
688                        if item_idx == 0 {
689                            self.engine.editor_wrap = !self.engine.editor_wrap;
690                            self.engine.dirty = true;
691                            let html = self.engine.last_html.clone();
692                            self.engine.parse_and_layout(
693                                &html,
694                                None,
695                                0,
696                                self.engine.win_w,
697                                self.engine.win_h,
698                            );
699                        }
700                    } else if menu_idx == 4 {
701                        // View
702                        if item_idx == 2 {
703                            self.output_panel_open = !self.output_panel_open;
704                            crate::kernel::window_mgr::get_instance().dirty = true;
705                        }
706                    }
707                }
708                self.open_menu = None;
709                return;
710            }
711
712            // メニューバークリック
713            if local_y < MENU_HEIGHT as i32 {
714                if (5..60).contains(&local_x) {
715                    self.open_menu = Some(0);
716                } else if (60..115).contains(&local_x) {
717                    self.open_menu = Some(1);
718                } else if (115..170).contains(&local_x) {
719                    self.open_menu = None;
720                    self.run_script();
721                } else if (170..225).contains(&local_x) {
722                    self.open_menu = Some(3);
723                } else if (225..280).contains(&local_x) {
724                    self.open_menu = Some(4);
725                } else if (280..335).contains(&local_x) {
726                    self.open_menu = Some(5);
727                }
728                return;
729            }
730        }
731
732        // テキストエリアへのイベント委譲
733        let editor_h = if self.output_panel_open {
734            self.last_win_h
735                .saturating_sub(MENU_HEIGHT)
736                .saturating_sub(self.output_panel_height)
737        } else {
738            self.last_win_h.saturating_sub(MENU_HEIGHT)
739        };
740        if self.open_menu.is_none()
741            && local_y >= MENU_HEIGHT as i32
742            && local_y < (MENU_HEIGHT + editor_h) as i32
743        {
744            self.engine.on_mouse(
745                local_x,
746                local_y - MENU_HEIGHT as i32,
747                btn_left,
748                btn_right,
749                wheel,
750                0,
751            );
752        }
753    }
754
755    fn on_key(&mut self, keycode: u8, pressed: bool, ascii: Option<char>) {
756        // Ctrl キー状態を追跡
757        if keycode == 0x1D {
758            self.ctrl_held = pressed;
759            return;
760        }
761
762        // Alt キー状態を追跡
763        if keycode == 0x38 {
764            self.alt_held = pressed;
765            return;
766        }
767
768        // Alt+P で出力ペインをトグル
769        if pressed && self.alt_held && keycode == 0x19 {
770            self.output_panel_open = !self.output_panel_open;
771            crate::kernel::window_mgr::get_instance().dirty = true;
772            return;
773        }
774
775        if self.panel_mode > 0 {
776            if !pressed {
777                return;
778            }
779            match keycode {
780                0x01 => {
781                    self.panel_mode = 0;
782                } // ESC
783                0x1C => {
784                    // Enter
785                    if !self.panel_input.is_empty() {
786                        if let Some(tab) = self.tabs_data.get_mut(self.active_idx) {
787                            tab.filename = self.panel_input.clone();
788                        }
789                        if self.panel_mode == 1 {
790                            self.load_file_into_current();
791                        } else {
792                            self.save_current();
793                        }
794                        self.panel_mode = 0;
795                    }
796                }
797                0x0E => {
798                    self.panel_input.pop();
799                } // Backspace
800                _ => {
801                    if let Some(ch) = ascii {
802                        if ch.is_ascii_alphanumeric()
803                            || ch == '.'
804                            || ch == '_'
805                            || ch == '-'
806                            || ch == '/'
807                        {
808                            self.panel_input.push(ch);
809                        }
810                    }
811                }
812            }
813            crate::kernel::window_mgr::get_instance().dirty = true;
814            return;
815        }
816
817        if self.open_menu.is_some() {
818            if pressed && keycode == 0x01 {
819                self.open_menu = None;
820            }
821            return;
822        }
823
824        // Ctrl+ショートカットはエディタ内操作(Emacs互換)に委ねる
825        // ファイル操作は Alt+O / Alt+S(OSグローバルショートカット)
826
827        self.engine.on_key(keycode, pressed, ascii);
828    }
829
830    fn open_file_dialog(&mut self) {
831        self.open_panel(1);
832    }
833
834    fn save_file(&mut self) {
835        if self
836            .tabs_data
837            .get(self.active_idx)
838            .map(|t| t.filename == "Untitled")
839            .unwrap_or(true)
840        {
841            self.open_panel(2); // ファイル名未設定なら Save As ダイアログへ
842        } else {
843            self.save_current();
844        }
845    }
846
847    fn tabs(&self) -> Vec<String> {
848        self.tabs_data.iter().map(|t| t.title()).collect()
849    }
850
851    fn active_tab(&self) -> usize {
852        self.active_idx
853    }
854
855    fn switch_tab(&mut self, idx: usize) {
856        if idx >= self.tabs_data.len() || idx == self.active_idx {
857            return;
858        }
859        self.flush_current_tab();
860        self.active_idx = idx;
861        self.load_tab_into_engine(idx);
862    }
863
864    fn add_tab(&mut self) {
865        self.new_tab_action();
866    }
867
868    fn close_tab(&mut self, idx: usize) -> bool {
869        if self.tabs_data.is_empty() {
870            return true;
871        }
872        self.flush_current_tab();
873        self.tabs_data.remove(idx);
874        if self.tabs_data.is_empty() {
875            self.engine.active = false;
876            return true;
877        }
878        self.active_idx = self.active_idx.min(self.tabs_data.len() - 1);
879        let new_idx = self.active_idx;
880        self.load_tab_into_engine(new_idx);
881        crate::kernel::window_mgr::get_instance().dirty = true;
882        false
883    }
884
885    fn get_dom_value(&self, id: &str) -> Option<String> {
886        if id == "filename" {
887            self.tabs_data
888                .get(self.active_idx)
889                .map(|t| t.filename.clone())
890        } else {
891            self.engine.form_values.get(id).cloned()
892        }
893    }
894
895    fn set_dom_value(&mut self, id: &str, val: &str) {
896        if id == "filename" {
897            if let Some(tab) = self.tabs_data.get_mut(self.active_idx) {
898                tab.filename = String::from(val);
899            }
900            self.engine
901                .form_values
902                .insert(String::from("filename"), String::from(val));
903        } else {
904            self.engine
905                .form_values
906                .insert(String::from(id), String::from(val));
907        }
908        self.engine.dirty = true;
909    }
910}