Skip to main content

atmos/apps/files/
mod.rs

1//! SylFS 用の 2x2 グリッド型ファイラアプリ。
2//!
3//! [`crate::kernel::fs`](SylFS)をブラウズ・コピー・移動・削除するための GUI。
4//! 上段=フォルダ一覧、下段=ファイル一覧の2ペイン構成で、キーボードショートカット
5//! ([`crate::kernel::shortcut_manager`])中心の操作を提供する。
6// src/files_app.rs - 2x2 Grid Stateless Filer for AtmOS
7#![allow(dead_code)]
8
9extern crate alloc;
10use alloc::boxed::Box;
11use alloc::string::String;
12use alloc::string::ToString;
13use alloc::vec;
14use alloc::vec::Vec;
15
16use crate::apps::browser::WebBrowser;
17use crate::kernel::config;
18use crate::kernel::draw::{Color, Screen};
19use crate::kernel::fs;
20use crate::kernel::shortcut_manager::{
21    InputAction, Modifiers, Shortcut, ShortcutLayer, ShortcutManager,
22};
23use crate::kernel::window_mgr::{App, Window};
24
25static ICON_FOLDER: &[u8] = include_bytes!("../../../icons/ic_fluent_folder_20_regular.svg");
26static ICON_FILE: &[u8] = include_bytes!("../../../icons/ic_fluent_document_20_regular.svg");
27
28// --- Filer App Commands ---
29const CMD_NAV_UP: u32 = 1;
30const CMD_NAV_DOWN: u32 = 2;
31const CMD_NAV_LEFT: u32 = 3;
32const CMD_NAV_RIGHT: u32 = 4;
33const CMD_FOCUS_UP: u32 = 5;
34const CMD_FOCUS_DOWN: u32 = 6;
35const CMD_FOCUS_LEFT: u32 = 7;
36const CMD_FOCUS_RIGHT: u32 = 8;
37const CMD_TAB_PREV: u32 = 9;
38const CMD_TAB_NEXT: u32 = 10;
39const CMD_FILE_COPY: u32 = 11;
40const CMD_FILE_MOVE: u32 = 12;
41const CMD_ENTER: u32 = 13;
42const CMD_DELETE: u32 = 14;
43
44#[derive(Clone)]
45pub struct FilerState {
46    pub current_path: String,
47    pub folders: Vec<String>,
48    pub files: Vec<fs::FileMetadata>,
49    pub selected_folder: usize,
50    pub selected_file: usize,
51    pub scroll_folder: usize,
52    pub scroll_file: usize,
53    pub active_pane: usize, // 0: folder (upper), 1: file (lower)
54}
55
56impl FilerState {
57    pub fn new(initial_path: &str) -> Self {
58        let mut state = Self {
59            current_path: String::from(initial_path),
60            folders: Vec::new(),
61            files: Vec::new(),
62            selected_folder: 0,
63            selected_file: 0,
64            scroll_folder: 0,
65            scroll_file: 0,
66            active_pane: 0,
67        };
68        state.refresh();
69        state
70    }
71
72    pub fn refresh(&mut self) {
73        self.folders.clear();
74        self.files.clear();
75
76        let all_files = fs::get_fs().list_files();
77        let mut folder_set = alloc::collections::BTreeSet::new();
78
79        let search_path = if self.current_path.ends_with('/') {
80            self.current_path.clone()
81        } else {
82            alloc::format!("{}/", self.current_path)
83        };
84
85        for meta in all_files {
86            let filename = meta.get_filename();
87            if filename.starts_with(&search_path) {
88                let remainder = filename.get(search_path.len()..).unwrap_or("");
89                if let Some(slash_idx) = remainder.find('/') {
90                    // It's a folder
91                    let folder_name = remainder.get(..slash_idx).unwrap_or("");
92                    if !folder_name.is_empty() {
93                        folder_set.insert(String::from(folder_name));
94                    }
95                } else if !remainder.is_empty() {
96                    // It's a file in the current directory
97                    self.files.push(meta);
98                }
99            } else if search_path == "/" && !filename.is_empty() && !filename.starts_with('/') {
100                // Handle files/folders at root without leading slash (though AtmOS paths usually start with /)
101                if let Some(slash_idx) = filename.find('/') {
102                    folder_set.insert(String::from(filename.get(..slash_idx).unwrap_or("")));
103                } else {
104                    self.files.push(meta);
105                }
106            }
107        }
108
109        // 特別な仮想ディレクトリ "apps", "user", "sys" などをルートに強制表示(存在しなくても表示させるため)
110        if self.current_path == "/" {
111            folder_set.insert(String::from("apps"));
112            folder_set.insert(String::from("user"));
113            folder_set.insert(String::from("sys"));
114        }
115
116        for f in folder_set {
117            self.folders.push(f);
118        }
119
120        if self.selected_folder >= self.folders.len() {
121            self.selected_folder = self.folders.len().saturating_sub(1);
122        }
123        if self.selected_file >= self.files.len() {
124            self.selected_file = self.files.len().saturating_sub(1);
125        }
126    }
127}
128
129#[derive(Clone)]
130pub struct TabState {
131    pub left: FilerState,
132    pub right: FilerState,
133    pub active_panel: usize,
134}
135
136pub struct FilesApp {
137    tabs: Vec<TabState>,
138    active_tab: usize,
139    status: String,
140    last_win_w: u32,
141    last_win_h: u32,
142    alt_held: bool,
143    ctrl_held: bool,
144    shortcut_mgr: ShortcutManager,
145}
146
147impl Default for FilesApp {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153impl FilesApp {
154    pub fn new() -> Self {
155        let tabs = vec![TabState {
156            left: FilerState::new("/"),
157            right: FilerState::new("/apps"),
158            active_panel: 0,
159        }];
160
161        let mut shortcut_mgr = ShortcutManager::new();
162
163        // --- 3キー操作 (優先度最高) ---
164        shortcut_mgr.register(Shortcut::new(
165            0x48,
166            Modifiers::new(true, true, false),
167            ShortcutLayer::App,
168            InputAction::AppCommand(CMD_FOCUS_UP),
169        )); // Ctrl+Alt+Up
170        shortcut_mgr.register(Shortcut::new(
171            0x50,
172            Modifiers::new(true, true, false),
173            ShortcutLayer::App,
174            InputAction::AppCommand(CMD_FOCUS_DOWN),
175        )); // Ctrl+Alt+Down
176        shortcut_mgr.register(Shortcut::new(
177            0x4B,
178            Modifiers::new(true, true, false),
179            ShortcutLayer::App,
180            InputAction::AppCommand(CMD_FOCUS_LEFT),
181        )); // Ctrl+Alt+Left
182        shortcut_mgr.register(Shortcut::new(
183            0x4D,
184            Modifiers::new(true, true, false),
185            ShortcutLayer::App,
186            InputAction::AppCommand(CMD_FOCUS_RIGHT),
187        )); // Ctrl+Alt+Right
188
189        // --- 2キー操作 ---
190        shortcut_mgr.register(Shortcut::new(
191            0x19,
192            Modifiers::new(true, false, false),
193            ShortcutLayer::App,
194            InputAction::AppCommand(CMD_NAV_UP),
195        )); // Ctrl+P
196        shortcut_mgr.register(Shortcut::new(
197            0x31,
198            Modifiers::new(true, false, false),
199            ShortcutLayer::App,
200            InputAction::AppCommand(CMD_NAV_DOWN),
201        )); // Ctrl+N
202        shortcut_mgr.register(Shortcut::new(
203            0x21,
204            Modifiers::new(true, false, false),
205            ShortcutLayer::App,
206            InputAction::AppCommand(CMD_NAV_RIGHT),
207        )); // Ctrl+F
208        shortcut_mgr.register(Shortcut::new(
209            0x30,
210            Modifiers::new(true, false, false),
211            ShortcutLayer::App,
212            InputAction::AppCommand(CMD_NAV_LEFT),
213        )); // Ctrl+B
214
215        shortcut_mgr.register(Shortcut::new(
216            0x48,
217            Modifiers::new(false, true, false),
218            ShortcutLayer::App,
219            InputAction::AppCommand(CMD_TAB_PREV),
220        )); // Alt+Up
221        shortcut_mgr.register(Shortcut::new(
222            0x50,
223            Modifiers::new(false, true, false),
224            ShortcutLayer::App,
225            InputAction::AppCommand(CMD_TAB_NEXT),
226        )); // Alt+Down
227        shortcut_mgr.register(Shortcut::new(
228            0x2E,
229            Modifiers::new(false, true, false),
230            ShortcutLayer::App,
231            InputAction::AppCommand(CMD_FILE_COPY),
232        )); // Alt+C
233        shortcut_mgr.register(Shortcut::new(
234            0x32,
235            Modifiers::new(false, true, false),
236            ShortcutLayer::App,
237            InputAction::AppCommand(CMD_FILE_MOVE),
238        )); // Alt+M
239
240        // --- 1キー操作 (単独) ---
241        shortcut_mgr.register(Shortcut::new(
242            0x48,
243            Modifiers::new(false, false, false),
244            ShortcutLayer::App,
245            InputAction::AppCommand(CMD_NAV_UP),
246        )); // Up
247        shortcut_mgr.register(Shortcut::new(
248            0x50,
249            Modifiers::new(false, false, false),
250            ShortcutLayer::App,
251            InputAction::AppCommand(CMD_NAV_DOWN),
252        )); // Down
253        shortcut_mgr.register(Shortcut::new(
254            0x4D,
255            Modifiers::new(false, false, false),
256            ShortcutLayer::App,
257            InputAction::AppCommand(CMD_NAV_RIGHT),
258        )); // Right
259        shortcut_mgr.register(Shortcut::new(
260            0x4B,
261            Modifiers::new(false, false, false),
262            ShortcutLayer::App,
263            InputAction::AppCommand(CMD_NAV_LEFT),
264        )); // Left
265        shortcut_mgr.register(Shortcut::new(
266            0x1C,
267            Modifiers::new(false, false, false),
268            ShortcutLayer::App,
269            InputAction::AppCommand(CMD_ENTER),
270        )); // Enter
271        shortcut_mgr.register(Shortcut::new(
272            0x53,
273            Modifiers::new(false, false, false),
274            ShortcutLayer::App,
275            InputAction::AppCommand(CMD_DELETE),
276        )); // Delete
277
278        Self {
279            tabs,
280            active_tab: 0,
281            status: String::from("Ready"),
282            last_win_w: 1000,
283            last_win_h: 600,
284            alt_held: false,
285            ctrl_held: false,
286            shortcut_mgr,
287        }
288    }
289
290    fn active_panel(&self) -> usize {
291        self.tabs[self.active_tab].active_panel
292    }
293
294    fn set_active_panel(&mut self, val: usize) {
295        self.tabs[self.active_tab].active_panel = val;
296    }
297
298    fn active_state(&mut self) -> &mut FilerState {
299        let active_tab = self.active_tab;
300        let tab = &mut self.tabs[active_tab];
301        if tab.active_panel == 0 {
302            &mut tab.left
303        } else {
304            &mut tab.right
305        }
306    }
307
308    fn inactive_state(&mut self) -> &mut FilerState {
309        let active_tab = self.active_tab;
310        let tab = &mut self.tabs[active_tab];
311        if tab.active_panel == 1 {
312            &mut tab.left
313        } else {
314            &mut tab.right
315        }
316    }
317
318    fn refresh_all(&mut self) {
319        for tab in self.tabs.iter_mut() {
320            tab.left.refresh();
321            tab.right.refresh();
322        }
323    }
324
325    fn get_active_file_path(&mut self) -> Option<String> {
326        let state = self.active_state();
327        if state.active_pane == 1 && !state.files.is_empty() {
328            Some(String::from(
329                state.files[state.selected_file].get_filename(),
330            ))
331        } else {
332            None
333        }
334    }
335
336    fn open_selected(&mut self) {
337        if let Some(filename) = self.get_active_file_path() {
338            // アプリケーションパッケージ内のファイルであれば、アプリを起動する
339            if filename.starts_with("/apps/") {
340                let parts: Vec<&str> = filename.split('/').collect();
341                if parts.len() > 2 {
342                    let app_name = parts[2];
343                    if app_name == "note" {
344                        let mut app = crate::apps::note::NoteApp::new();
345                        if let Some((_meta, content)) =
346                            fs::get_fs().read_file("/apps/note/index.html")
347                        {
348                            if let Ok(html_str) = core::str::from_utf8(&content) {
349                                app.engine.load_html(html_str, "/apps/note/index.html", 0);
350                            }
351                        }
352                        let win = Window::new(200, 150, 600, 450, "Note - Untitled", Box::new(app));
353                        crate::kernel::window_mgr::get_instance().add_window(win);
354                        self.status = "Launched Note App".to_string();
355                        return;
356                    } else if app_name == "settings" {
357                        let app = crate::apps::settings::SettingsApp::new();
358                        let win = Window::new(150, 100, 480, 560, "OS Settings", Box::new(app));
359                        crate::kernel::window_mgr::get_instance().add_window(win);
360                        self.status = "Launched Settings App".to_string();
361                        return;
362                    } else if app_name == "music" {
363                        let app = crate::apps::music::MusicApp::new();
364                        let win = Window::new(180, 120, 500, 420, "Music Player", Box::new(app));
365                        crate::kernel::window_mgr::get_instance().add_window(win);
366                        self.status = "Launched Music Player".to_string();
367                        return;
368                    } else if app_name == "files" {
369                        let app = crate::apps::files::FilesApp::new();
370                        let win = Window::new(140, 90, 1180, 760, "Files", Box::new(app));
371                        crate::kernel::window_mgr::get_instance().add_window(win);
372                        self.status = "Launched Files App".to_string();
373                        return;
374                    } else if app_name == "browser" {
375                        // WebBrowser::new() が OS 内蔵の静的ホームページを表示する(ネットワーク不要)
376                        let app = WebBrowser::new();
377                        let win = Window::new(100, 100, 800, 600, "Browser - AtmOS", Box::new(app));
378                        crate::kernel::window_mgr::get_instance().add_window(win);
379                        self.status = "Launched Browser App".to_string();
380                        return;
381                    }
382                }
383            }
384
385            if filename.ends_with(".html") || filename.ends_with(".htm") {
386                let mut app = WebBrowser::new();
387                if let Some((_meta, content)) = fs::get_fs().read_file(&filename) {
388                    if let Ok(html_str) = core::str::from_utf8(&content) {
389                        let top = app.top_offset();
390                        app.engine.load_html(html_str, &filename, top);
391                    }
392                }
393                let win = Window::new(
394                    100,
395                    100,
396                    800,
397                    600,
398                    &alloc::format!("Browser - {}", filename),
399                    Box::new(app),
400                );
401                crate::kernel::window_mgr::get_instance().add_window(win);
402                self.status = alloc::format!("Opened HTML file '{}'", filename);
403            } else {
404                let content = fs::get_fs()
405                    .read_file(&filename)
406                    .and_then(|(_meta, bytes)| {
407                        core::str::from_utf8(&bytes)
408                            .ok()
409                            .map(alloc::string::String::from)
410                    })
411                    .unwrap_or_default();
412                let app = crate::apps::note::NoteApp::open_file(&filename, &content);
413                let win = Window::new(
414                    200,
415                    150,
416                    600,
417                    450,
418                    &alloc::format!("Note - {}", filename),
419                    Box::new(app),
420                );
421                crate::kernel::window_mgr::get_instance().add_window(win);
422                self.status = alloc::format!("Opened '{}' in Note", filename);
423            }
424        }
425    }
426
427    fn copy_to_inactive_panel(&mut self) {
428        if let Some(src_path) = self.get_active_file_path() {
429            let dest_path_base = self.inactive_state().current_path.clone();
430
431            let dest_dir = if dest_path_base.ends_with('/') {
432                dest_path_base
433            } else {
434                alloc::format!("{}/", dest_path_base)
435            };
436
437            let filename_only = match src_path.rfind('/') {
438                Some(idx) => src_path.get(idx + 1..).unwrap_or(""),
439                None => &src_path,
440            };
441
442            let dest_path = alloc::format!("{}{}", dest_dir, filename_only);
443
444            if let Some((meta, content)) = fs::get_fs().read_file(&src_path) {
445                if fs::get_fs()
446                    .save_file(&dest_path, &content, meta.get_labels())
447                    .is_ok()
448                {
449                    self.status = alloc::format!("Copied to {}", dest_path);
450                    self.refresh_all();
451                    return;
452                }
453            }
454            self.status = alloc::format!("Failed to copy {}", src_path);
455        }
456    }
457
458    fn move_to_inactive_panel(&mut self) {
459        if let Some(src_path) = self.get_active_file_path() {
460            let dest_path_base = self.inactive_state().current_path.clone();
461
462            let dest_dir = if dest_path_base.ends_with('/') {
463                dest_path_base
464            } else {
465                alloc::format!("{}/", dest_path_base)
466            };
467            let filename_only = match src_path.rfind('/') {
468                Some(idx) => src_path.get(idx + 1..).unwrap_or(""),
469                None => &src_path,
470            };
471            let dest_path = alloc::format!("{}{}", dest_dir, filename_only);
472
473            let fs_ref = fs::get_fs();
474            if let Some((meta, content)) = fs_ref.read_file(&src_path) {
475                if fs_ref
476                    .save_file(&dest_path, &content, meta.get_labels())
477                    .is_ok()
478                {
479                    let _ = fs_ref.delete_file(&src_path);
480                    self.status = alloc::format!("Moved to {}", dest_path);
481                    self.refresh_all();
482                    return;
483                }
484            }
485            self.status = alloc::format!("Failed to move {}", src_path);
486        }
487    }
488
489    fn delete_selected(&mut self) {
490        if let Some(filename) = self.get_active_file_path() {
491            if fs::get_fs().delete_file(&filename).is_ok() {
492                self.status = alloc::format!("Deleted '{}'", filename);
493                crate::kernel::audio::play_system_se(crate::kernel::audio::SeType::Delete);
494                self.refresh_all();
495            } else {
496                self.status = alloc::format!("ERROR: Failed to delete '{}'", filename);
497            }
498        }
499    }
500
501    fn get_folders_at(path: &str) -> Vec<String> {
502        let mut folder_set = alloc::collections::BTreeSet::new();
503        let all_files = fs::get_fs().list_files();
504
505        let search_path = if path.ends_with('/') {
506            path.to_string()
507        } else {
508            alloc::format!("{}/", path)
509        };
510
511        for meta in all_files {
512            let filename = meta.get_filename();
513            if filename.starts_with(&search_path) {
514                let remainder = filename.get(search_path.len()..).unwrap_or("");
515                if let Some(slash_idx) = remainder.find('/') {
516                    let folder_name = remainder.get(..slash_idx).unwrap_or("");
517                    if !folder_name.is_empty() {
518                        folder_set.insert(String::from(folder_name));
519                    }
520                }
521            }
522        }
523
524        if path == "/" {
525            folder_set.insert(String::from("apps"));
526            folder_set.insert(String::from("user"));
527            folder_set.insert(String::from("sys"));
528        }
529
530        folder_set.into_iter().collect()
531    }
532
533    fn draw_panel(
534        &self,
535        screen: &Screen,
536        is_right: bool,
537        px: u32,
538        py: u32,
539        pw: u32,
540        ph: u32,
541        active_panel: bool,
542    ) {
543        let tab = &self.tabs[self.active_tab];
544        let state = if is_right {
545            &tab.right
546        } else {
547            &tab.left
548        };
549
550        let config = config::get_config();
551        let bg = Color(config.theme.desktop_bg);
552        let list_bg = Color(config.theme.terminal_bg);
553        let fg = Color(config.theme.terminal_fg);
554        let active_color = Color(config.theme.active_window_border);
555        let inactive_color = Color(config.theme.non_active_window_border);
556        let preview_color = Color(config.theme.non_active_window_border);
557
558        // Panel Border
559        let border_color = if active_panel {
560            active_color
561        } else {
562            inactive_color
563        };
564        screen.boxfill(px, py, px + pw, py + ph, border_color);
565        screen.boxfill(px + 2, py + 2, px + pw - 2, py + ph - 2, bg);
566
567        let half_h = (ph - 4) / 2;
568
569        // --- Upper Pane: Horizontal Columns (Parent, Current, Child Preview) ---
570        let upper_py = py + 2;
571        let upper_ph = half_h;
572        let upper_active = active_panel && state.active_pane == 0;
573
574        screen.boxfill(
575            px + 4,
576            upper_py + 4,
577            px + pw - 4,
578            upper_py + upper_ph - 4,
579            list_bg,
580        );
581
582        // Draw Current Path Header
583        screen.draw_string_vector(
584            px + 8,
585            upper_py + 8,
586            &alloc::format!("Path: {}", state.current_path),
587            Color(config.theme.focus_highlight),
588            14,
589        );
590
591        // 3-Column horizontal division
592        let col_w = (pw - 20) / 3;
593        let col_h = upper_ph - 38;
594        let col_y = upper_py + 36;
595
596        // Draw Column Headers (Parent, Current, Child Preview)
597        let header_y = upper_py + 22;
598        screen.draw_string_vector(px + 10, header_y, "Parent", Color(config.theme.non_active_window_border), 10);
599        screen.draw_string_vector(px + 10 + col_w, header_y, "Current", Color(config.theme.active_window_border), 10);
600        screen.draw_string_vector(
601            px + 10 + col_w * 2,
602            header_y,
603            "Child Preview",
604            Color(config.theme.non_active_window_border),
605            10,
606        );
607
608        // 1. Column 1 (Left): Parent Directory Folders
609        let parent_path = if state.current_path == "/" {
610            String::from("/")
611        } else {
612            let mut p = state.current_path.trim_end_matches('/').to_string();
613            if let Some(idx) = p.rfind('/') {
614                p.truncate(idx + 1);
615                if p.is_empty() {
616                    String::from("/")
617                } else {
618                    p
619                }
620            } else {
621                String::from("/")
622            }
623        };
624
625        let parent_folders = if state.current_path == "/" {
626            Vec::new()
627        } else {
628            Self::get_folders_at(&parent_path)
629        };
630
631        // Draw column separator 1
632        screen.boxfill(
633            px + 4 + col_w,
634            col_y,
635            px + 4 + col_w + 1,
636            col_y + col_h,
637            Color(config.theme.non_active_window_border),
638        );
639
640        // Determine basename of current path for matching in parent column
641        let trimmed_cur = state.current_path.trim_end_matches('/');
642        let current_basename = match trimmed_cur.rfind('/') {
643            Some(idx) => trimmed_cur.get(idx + 1..).unwrap_or(""),
644            None => state.current_path.trim_start_matches('/'),
645        };
646
647        let line_h = 20;
648        let visible_lines = (col_h / line_h) as usize;
649
650        for i in 0..visible_lines {
651            if i >= parent_folders.len() {
652                break;
653            }
654            let y = col_y + i as u32 * line_h;
655            let is_matched = parent_folders[i] == current_basename;
656            let col_x0 = px + 6;
657
658            if is_matched {
659                // Highlight the matched parent folder with a subtle gray box
660                screen.boxfill(
661                    col_x0,
662                    y,
663                    px + 4 + col_w - 2,
664                    y + line_h - 2,
665                    Color(config.theme.button_bg),
666                );
667            }
668
669            screen.draw_svg_icon(col_x0 + 4, y + 3, ICON_FOLDER, 12, preview_color);
670            screen.draw_string_vector(
671                col_x0 + 20,
672                y + 2,
673                &alloc::format!("{} <", parent_folders[i]),
674                preview_color,
675                12,
676            );
677        }
678
679        // 2. Column 2 (Center): Current Directory Folders (Active Focus Column)
680        // Draw column separator 2
681        screen.boxfill(
682            px + 6 + col_w * 2,
683            col_y,
684            px + 6 + col_w * 2 + 1,
685            col_y + col_h,
686            Color(config.theme.non_active_window_border),
687        );
688
689        for i in 0..visible_lines {
690            let idx = state.scroll_folder + i;
691            if idx >= state.folders.len() {
692                break;
693            }
694
695            let y = col_y + i as u32 * line_h;
696            let selected = idx == state.selected_folder;
697            let col_x0 = px + 6 + col_w;
698            let col_x1 = px + 6 + col_w * 2 - 2;
699
700            if selected {
701                let sel_bg = if upper_active {
702                    active_color
703                } else {
704                    inactive_color
705                };
706                screen.boxfill(col_x0, y, col_x1, y + line_h - 2, sel_bg);
707                // フォーカスハイライト枠: アクティブパネルの選択行に明るいボーダー
708                if upper_active {
709                    let bord = Color(config.theme.focus_highlight);
710                    screen.boxfill(col_x0, y, col_x1, y + 1, bord);
711                    screen.boxfill(col_x0, y + line_h - 3, col_x1, y + line_h - 2, bord);
712                    screen.boxfill(col_x0, y, col_x0 + 1, y + line_h - 2, bord);
713                    screen.boxfill(col_x1 - 1, y, col_x1, y + line_h - 2, bord);
714                }
715            }
716
717            let fg_color = if selected && upper_active {
718                Color(config.theme.button_active_fg)
719            } else {
720                fg
721            };
722            let icon_color = if selected && upper_active {
723                Color(config.theme.active_window_border)
724            } else {
725                fg_color
726            };
727            screen.draw_svg_icon(col_x0 + 4, y + 3, ICON_FOLDER, 12, icon_color);
728            screen.draw_string_vector(
729                col_x0 + 20,
730                y + 2,
731                &alloc::format!("{} >", state.folders[idx]),
732                fg_color,
733                12,
734            );
735        }
736
737        // 3. Column 3 (Right): Child Directory Preview (Subfolders under currently selected folder)
738        let child_folders =
739            if !state.folders.is_empty() && state.selected_folder < state.folders.len() {
740                let mut child_path = state.current_path.clone();
741                if !child_path.ends_with('/') {
742                    child_path.push('/');
743                }
744                child_path.push_str(&state.folders[state.selected_folder]);
745                Self::get_folders_at(&child_path)
746            } else {
747                Vec::new()
748            };
749
750        for i in 0..visible_lines {
751            if i >= child_folders.len() {
752                break;
753            }
754            let y = col_y + i as u32 * line_h;
755            let col_x0 = px + 8 + col_w * 2;
756
757            screen.draw_svg_icon(col_x0 + 4, y + 3, ICON_FOLDER, 12, preview_color);
758            screen.draw_string_vector(col_x0 + 20, y + 2, &child_folders[i], preview_color, 12);
759        }
760
761        // --- Lower Pane: List (Files) ---
762        let lower_py = py + 2 + half_h;
763        let lower_ph = half_h;
764        let lower_active = active_panel && state.active_pane == 1;
765
766        screen.boxfill(
767            px + 4,
768            lower_py + 4,
769            px + pw - 4,
770            lower_py + lower_ph - 4,
771            list_bg,
772        );
773
774        let visible_files = ((lower_ph - 12) / line_h) as usize;
775
776        for i in 0..visible_files {
777            let idx = state.scroll_file + i;
778            if idx >= state.files.len() {
779                break;
780            }
781
782            let y = lower_py + 8 + i as u32 * line_h;
783            let selected = idx == state.selected_file;
784
785            if selected {
786                let sel_bg = if lower_active {
787                    active_color
788                } else {
789                    inactive_color
790                };
791                let x0 = px + 6;
792                let x1 = px + pw - 6;
793                screen.boxfill(x0, y, x1, y + line_h - 2, sel_bg);
794                // フォーカスハイライト枠
795                if lower_active {
796                    let bord = Color(config.theme.focus_highlight);
797                    screen.boxfill(x0, y, x1, y + 1, bord);
798                    screen.boxfill(x0, y + line_h - 3, x1, y + line_h - 2, bord);
799                    screen.boxfill(x0, y, x0 + 1, y + line_h - 2, bord);
800                    screen.boxfill(x1 - 1, y, x1, y + line_h - 2, bord);
801                }
802            }
803
804            let fg_color = if selected && lower_active {
805                Color(config.theme.button_active_fg)
806            } else {
807                fg
808            };
809            let filename = state.files[idx].get_filename();
810
811            // Extract just the basename for display
812            let basename = match filename.rfind('/') {
813                Some(slash_idx) => filename.get(slash_idx + 1..).unwrap_or(""),
814                None => filename,
815            };
816
817            let icon_color = if selected && lower_active {
818                Color(config.theme.active_window_border)
819            } else {
820                fg_color
821            };
822            screen.draw_svg_icon(px + 12, y + 3, ICON_FILE, 12, icon_color);
823            screen.draw_string_vector(px + 30, y + 2, basename, fg_color, 14);
824        }
825    }
826}
827
828impl App for FilesApp {
829    fn name(&self) -> &str {
830        "files"
831    }
832
833    fn draw(&mut self, screen: &Screen, win_x: u32, win_y: u32, win_w: u32, win_h: u32) {
834        self.last_win_w = win_w;
835        self.last_win_h = win_h;
836
837        let config = config::get_config();
838        let bg_color = Color(config.theme.desktop_bg);
839        let header_bg = Color(config.theme.window_title_bar);
840
841        // Background
842        screen.boxfill(win_x, win_y, win_x + win_w, win_y + win_h, bg_color);
843
844        // Top Toolbar
845        screen.boxfill(win_x, win_y, win_x + win_w, win_y + 26, header_bg);
846        screen.draw_string_vector(
847            win_x + 10,
848            win_y + 5,
849            "2x2 Grid Filer",
850            Color(config.theme.window_title_fg),
851            14,
852        );
853
854        // Layout Dimensions
855        let panel_w = (win_w - 20) / 2;
856        let panel_h = win_h - 60; // Leave room for top toolbar and bottom status
857
858        // Left Panel
859        self.draw_panel(
860            screen,
861            false,
862            win_x + 6,
863            win_y + 30,
864            panel_w,
865            panel_h,
866            self.active_panel() == 0,
867        );
868
869        // Right Panel
870        self.draw_panel(
871            screen,
872            true,
873            win_x + 10 + panel_w,
874            win_y + 30,
875            panel_w,
876            panel_h,
877            self.active_panel() == 1,
878        );
879
880        // Status Bar
881        let footer_y = win_y + win_h - 24;
882        screen.boxfill(win_x, footer_y, win_x + win_w, win_y + win_h, header_bg);
883        screen.draw_string_vector(win_x + 8, footer_y + 4, &self.status, Color(config.theme.window_title_fg), 14);
884    }
885
886    fn on_mouse(
887        &mut self,
888        _local_x: i32,
889        _local_y: i32,
890        _btn_left: bool,
891        _btn_right: bool,
892        _wheel: i32,
893    ) {
894        // Filer is now keyboard-driven. Mouse support minimized.
895    }
896
897    fn on_key(&mut self, keycode: u8, pressed: bool, _ascii: Option<char>) {
898        if keycode == 0x38 {
899            // Alt key
900            self.alt_held = pressed;
901            return;
902        }
903
904        if keycode == 0x1D {
905            // Ctrl key
906            self.ctrl_held = pressed;
907            return;
908        }
909
910        if !pressed {
911            return;
912        }
913
914        // --- 1. ショートカットマネージャによる優先度付き評価 ---
915        let mods = Modifiers::new(self.ctrl_held, self.alt_held, false);
916        let action = self.shortcut_mgr.evaluate(keycode, mods);
917
918        match action {
919            InputAction::AppCommand(cmd) => {
920                match cmd {
921                    CMD_FOCUS_UP => {
922                        self.active_state().active_pane = 0;
923                    }
924                    CMD_FOCUS_DOWN => {
925                        self.active_state().active_pane = 1;
926                    }
927                    CMD_FOCUS_LEFT => {
928                        self.set_active_panel(0);
929                    }
930                    CMD_FOCUS_RIGHT => {
931                        self.set_active_panel(1);
932                    }
933
934                    CMD_TAB_PREV => {
935                        self.active_tab = self.active_tab.saturating_sub(1);
936                    }
937                    CMD_TAB_NEXT => {
938                        self.active_tab = (self.active_tab + 1).min(self.tabs.len().saturating_sub(1));
939                    }
940                    CMD_FILE_COPY => {
941                        self.copy_to_inactive_panel();
942                    }
943                    CMD_FILE_MOVE => {
944                        self.move_to_inactive_panel();
945                    }
946
947                    CMD_NAV_UP | CMD_NAV_DOWN | CMD_NAV_LEFT | CMD_NAV_RIGHT | CMD_ENTER
948                    | CMD_DELETE => {
949                        let state = self.active_state();
950                        let is_up = cmd == CMD_NAV_UP;
951                        let is_down = cmd == CMD_NAV_DOWN;
952                        let is_left = cmd == CMD_NAV_LEFT;
953                        let is_right = cmd == CMD_NAV_RIGHT;
954
955                        if state.active_pane == 0 {
956                            // Folder Pane
957                            if is_up && state.selected_folder > 0 {
958                                state.selected_folder -= 1;
959                                if state.selected_folder < state.scroll_folder {
960                                    state.scroll_folder = state.selected_folder;
961                                }
962                            } else if is_down && state.selected_folder + 1 < state.folders.len() {
963                                state.selected_folder += 1;
964                                if state.selected_folder >= state.scroll_folder + 10 {
965                                    state.scroll_folder += 1;
966                                }
967                            } else if is_right && !state.folders.is_empty() {
968                                // Enter Folder
969                                let folder_name = &state.folders[state.selected_folder];
970                                let mut new_path = state.current_path.clone();
971                                if !new_path.ends_with('/') {
972                                    new_path.push('/');
973                                }
974                                new_path.push_str(folder_name);
975                                state.current_path = new_path;
976                                state.refresh();
977                            } else if is_left && state.current_path != "/" {
978                                // Go Up Directory
979                                let mut p = state.current_path.trim_end_matches('/').to_string();
980                                if let Some(idx) = p.rfind('/') {
981                                    p.truncate(idx + 1);
982                                    if p.is_empty() {
983                                        p = String::from("/");
984                                    }
985                                } else {
986                                    p = String::from("/");
987                                }
988                                state.current_path = p;
989                                state.refresh();
990                            }
991                        } else {
992                            // File Pane
993                            if is_up && state.selected_file > 0 {
994                                state.selected_file -= 1;
995                                if state.selected_file < state.scroll_file {
996                                    state.scroll_file = state.selected_file;
997                                }
998                            } else if is_down && state.selected_file + 1 < state.files.len() {
999                                state.selected_file += 1;
1000                                if state.selected_file >= state.scroll_file + 10 {
1001                                    state.scroll_file += 1;
1002                                }
1003                            } else if cmd == CMD_ENTER {
1004                                // Enter -> Open
1005                            } else if cmd == CMD_DELETE {
1006                                // Delete -> Delete
1007                            }
1008                        }
1009                    }
1010                    _ => {}
1011                }
1012            }
1013            _ => {
1014                // Not an AppCommand, or Ignore
1015            }
1016        }
1017
1018        // --- 2. ミュータブル借用のスコープを分ける処理 ---
1019        // CMD_ENTER / CMD_DELETE は FilerApp 全体のメソッド呼び出しを伴うためここで処理
1020        if let InputAction::AppCommand(cmd) = action {
1021            if cmd == CMD_ENTER && self.active_state().active_pane == 1 {
1022                self.open_selected();
1023            } else if cmd == CMD_DELETE && self.active_state().active_pane == 1 {
1024                self.delete_selected();
1025            }
1026        }
1027    }
1028
1029    fn tabs(&self) -> Vec<String> {
1030        let mut titles = Vec::new();
1031        for (i, tab) in self.tabs.iter().enumerate() {
1032            let left_name = tab.left.current_path.trim_end_matches('/').split('/').next_back().unwrap_or("");
1033            let right_name = tab.right.current_path.trim_end_matches('/').split('/').next_back().unwrap_or("");
1034            let l_label = if left_name.is_empty() { "Root" } else { left_name };
1035            let r_label = if right_name.is_empty() { "Root" } else { right_name };
1036            titles.push(alloc::format!("{}: {} | {}", i + 1, l_label, r_label));
1037        }
1038        titles
1039    }
1040
1041    fn active_tab(&self) -> usize {
1042        self.active_tab
1043    }
1044
1045    fn close_tab(&mut self, idx: usize) -> bool {
1046        if self.tabs.len() > 1 && idx < self.tabs.len() {
1047            self.tabs.remove(idx);
1048            if self.active_tab >= self.tabs.len() {
1049                self.active_tab = self.tabs.len() - 1;
1050            }
1051            false
1052        } else {
1053            true
1054        }
1055    }
1056
1057    fn switch_tab(&mut self, idx: usize) {
1058        if idx < self.tabs.len() {
1059            self.active_tab = idx;
1060        }
1061    }
1062
1063    fn add_tab(&mut self) {
1064        self.tabs.push(TabState {
1065            left: FilerState::new("/"),
1066            right: FilerState::new("/apps"),
1067            active_panel: 0,
1068        });
1069        self.active_tab = self.tabs.len() - 1;
1070    }
1071}