diff --git a/src/config.rs b/src/config.rs index 9996a83..214871b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1002,23 +1002,8 @@ impl Config { if !m.path.is_dir() { anyhow::bail!("[[filesys]] path {} is not a directory", m.path.display()); } - // The volume name becomes an AmigaDOS volume label (a DosList - // BSTR): 1-30 bytes, and no ':' '/' or NUL. - let vol = &m.volume; - if vol.is_empty() { - anyhow::bail!("[[filesys]] volume name must not be empty"); - } - if vol.len() > 30 { - anyhow::bail!( - "[[filesys]] volume name {vol:?} is too long ({} bytes; max 30)", - vol.len() - ); - } - if vol.contains([':', '/', '\0']) { - anyhow::bail!( - "[[filesys]] volume name {vol:?} contains an invalid \ - character (no ':' '/' or NUL)" - ); + if let Some(err) = crate::filesys::volume_name_error(&m.volume) { + anyhow::bail!("[[filesys]] {err}"); } } chain.add_board_with_rom( @@ -1631,10 +1616,8 @@ fn drive_image(raw: RawDrive) -> Result { let trimmed = name.trim(); if trimmed.is_empty() { None - } else if trimmed.contains([':', '/']) { - bail!("drive name {name:?} must not contain ':' or '/'"); - } else if trimmed.chars().count() > 30 { - bail!("drive name {name:?} is too long (AmigaDOS volume names hold 30 characters)"); + } else if let Some(err) = crate::filesys::volume_name_error(trimmed) { + bail!("drive name: {err}"); } else { Some(trimmed.to_string()) } @@ -3689,7 +3672,7 @@ mod tests { "#, ) .unwrap_err(); - assert!(err.to_string().contains("must not contain"), "{err:#}"); + assert!(err.to_string().contains("invalid character"), "{err:#}"); // Over the 30-character FFS volume-label limit. let err = parse_config(&format!( diff --git a/src/filesys.rs b/src/filesys.rs index fdd9b6b..666467f 100644 --- a/src/filesys.rs +++ b/src/filesys.rs @@ -36,6 +36,28 @@ pub const MOUNT_ENTRY_SIZE: usize = 32; /// Maximum host mounts (units), and the divisor for each unit's fixed /// board-window lock-pool slice. pub const MOUNT_MAX_COUNT: usize = 8; +/// Longest AmigaDOS volume label: a DosList BSTR holds 30 bytes. +pub const VOLUME_NAME_MAX: usize = 30; + +/// Why `name` would not work as an AmigaDOS volume label, or `None` if it +/// would. Shared by the config validator and the launcher's name editor so +/// the GUI cannot save a name the config would reject. +pub fn volume_name_error(name: &str) -> Option { + if name.is_empty() { + Some("volume name must not be empty".to_string()) + } else if name.len() > VOLUME_NAME_MAX { + Some(format!( + "volume name {name:?} is too long ({} bytes; max {VOLUME_NAME_MAX})", + name.len() + )) + } else if name.contains([':', '/', '\0']) { + Some(format!( + "volume name {name:?} contains an invalid character (no ':' '/' or NUL)" + )) + } else { + None + } +} /// The DiagArea (`BoardSpec::copperline_services` points er_InitDiagVec /// here): embedded in the handler ROM at +0x40, like real autoboot boards /// carry theirs in the device ROM (see `_diag_area` in entry.s). diff --git a/src/video/launcher.rs b/src/video/launcher.rs index 583c4fd..03ed657 100644 --- a/src/video/launcher.rs +++ b/src/video/launcher.rs @@ -24,7 +24,7 @@ use crate::chipset::denise::DeniseRevision; use crate::config::{ format_size, machine_profile_defaults, ChannelMode, Chipset, Config, CpuModel, JoystickInputMode, MachineModel, Overscan, PacingBudget, PixelAspect, RawConfig, RawDrive, - RawFloppyDrive, RawZorroBoard, ScsiController, SerialMode, WarpSpeed, + RawFilesysMount, RawFloppyDrive, RawZorroBoard, ScsiController, SerialMode, WarpSpeed, }; use crate::zorro::{ConfigOption, ConfigOptionKind, LoadedZorroBoard}; use anyhow::Result; @@ -166,6 +166,7 @@ pub enum LauncherTab { Rom, Floppy, Storage, + HostFs, Cd, // Only reached in a `midi` build, where it is added to TABS. #[cfg_attr(not(feature = "midi"), allow(dead_code))] @@ -184,6 +185,7 @@ pub const TABS: &[LauncherTab] = &[ LauncherTab::Rom, LauncherTab::Floppy, LauncherTab::Storage, + LauncherTab::HostFs, LauncherTab::Cd, #[cfg(feature = "midi")] LauncherTab::Serial, @@ -200,6 +202,7 @@ impl LauncherTab { LauncherTab::Rom => "ROM", LauncherTab::Floppy => "Floppy", LauncherTab::Storage => "Hard Disk", + LauncherTab::HostFs => "Host Mounts", LauncherTab::Cd => "CD", LauncherTab::Serial => "Serial", LauncherTab::Zorro => "Zorro", @@ -258,6 +261,15 @@ pub enum LauncherField { ScsiUnit4, ScsiUnit5, ScsiUnit6, + // Host FS mounts (the GUI edits the first FILESYS_GUI_SLOTS entries) + Filesys0Dir, + Filesys0Boot, + Filesys1Dir, + Filesys1Boot, + Filesys2Dir, + Filesys2Boot, + Filesys3Dir, + Filesys3Boot, // CD CdImage, CdInsertDelay, @@ -311,6 +323,34 @@ const fn row(field: LauncherField, label: &'static str, kind: RowKind) -> Row { Row { field, label, kind } } +/// How many `[[filesys]]` mounts the launcher edits (the config file +/// accepts more; extras round-trip untouched). +pub const FILESYS_GUI_SLOTS: usize = 4; + +/// The Host FS mount slot a launcher field addresses, or `None` for other +/// fields: (mount index, whether the field is the boot-priority row). +fn filesys_slot(field: LauncherField) -> Option<(usize, bool)> { + Some(match field { + LauncherField::Filesys0Dir => (0, false), + LauncherField::Filesys0Boot => (0, true), + LauncherField::Filesys1Dir => (1, false), + LauncherField::Filesys1Boot => (1, true), + LauncherField::Filesys2Dir => (2, false), + LauncherField::Filesys2Boot => (2, true), + LauncherField::Filesys3Dir => (3, false), + LauncherField::Filesys3Boot => (3, true), + _ => return None, + }) +} + +impl LauncherField { + /// Whether this field is a Host FS mount's directory (folder picker), + /// as opposed to a boot-priority stepper or any other field. + pub fn is_filesys_dir_field(self) -> bool { + matches!(filesys_slot(self), Some((_, false))) + } +} + use LauncherField as F; use RowKind::{Cycle, Drive, Toggle}; // `RowKind::Path` is written out below so it does not collide with the @@ -367,6 +407,16 @@ const STORAGE_ROWS: [Row; 12] = [ row(F::ScsiUnit5, "SCSI unit 5", Drive), row(F::ScsiUnit6, "SCSI unit 6", Drive), ]; +const HOSTFS_ROWS: [Row; 8] = [ + row(F::Filesys0Dir, "HOSTFS0", Drive), + row(F::Filesys0Boot, " Boot priority", Cycle), + row(F::Filesys1Dir, "HOSTFS1", Drive), + row(F::Filesys1Boot, " Boot priority", Cycle), + row(F::Filesys2Dir, "HOSTFS2", Drive), + row(F::Filesys2Boot, " Boot priority", Cycle), + row(F::Filesys3Dir, "HOSTFS3", Drive), + row(F::Filesys3Boot, " Boot priority", Cycle), +]; const CD_ROWS: [Row; 3] = [ row(F::CdImage, "CD image", PathRow), row(F::CdInsertDelay, "Insert delay", Cycle), @@ -404,6 +454,7 @@ pub fn rows(tab: LauncherTab) -> &'static [Row] { LauncherTab::Rom => &ROM_ROWS, LauncherTab::Floppy => &FLOPPY_ROWS, LauncherTab::Storage => &STORAGE_ROWS, + LauncherTab::HostFs => &HOSTFS_ROWS, LauncherTab::Cd => &CD_ROWS, LauncherTab::Serial => serial_rows(), LauncherTab::Zorro => &[], @@ -569,6 +620,14 @@ pub struct MachineSetup { scsi_rom_odd: Option, scsi_units: [Option; 7], scsi_unit_names: [Option; 7], + // Host FS mounts. The GUI edits the first FILESYS_GUI_SLOTS entries + // (directory + optional volume name + boot priority, -128 = never boot); + // any further hand-written [[filesys]] entries are carried in + // `filesys_extra` and re-emitted verbatim so a save never drops them. + filesys_dirs: [Option; FILESYS_GUI_SLOTS], + filesys_names: [Option; FILESYS_GUI_SLOTS], + filesys_bootpri: [i8; FILESYS_GUI_SLOTS], + filesys_extra: Vec, // CD cd_image: Option, cd_insert_delay: f64, @@ -667,6 +726,21 @@ impl MachineSetup { .as_ref() .and_then(|d| d.volume_name.clone()) }), + filesys_dirs: std::array::from_fn(|i| { + raw.filesys.get(i).map(|m| PathBuf::from(&m.path)) + }), + filesys_names: std::array::from_fn(|i| { + raw.filesys.get(i).and_then(|m| m.volume.clone()) + }), + filesys_bootpri: std::array::from_fn(|i| { + raw.filesys.get(i).and_then(|m| m.bootpri).unwrap_or(-128) + }), + filesys_extra: raw + .filesys + .iter() + .skip(FILESYS_GUI_SLOTS) + .cloned() + .collect(), cd_image: cfg.cd_image_path.clone(), cd_insert_delay: cfg.cd_insert_delay_secs, // Use the raw NVRAM path: Config defaults it to "cd32-nvram.bin" @@ -874,6 +948,22 @@ impl MachineSetup { self.scsi_unit_names[6].as_deref(), ); } + // Host FS mounts: the edited slots (empty ones drop out), then any + // hand-written extras beyond what the GUI shows. + raw.filesys = (0..FILESYS_GUI_SLOTS) + .filter_map(|i| { + self.filesys_dirs[i].as_ref().map(|p| RawFilesysMount { + path: path_string(p), + volume: self.filesys_names[i] + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string), + bootpri: (self.filesys_bootpri[i] != -128).then_some(self.filesys_bootpri[i]), + }) + }) + .chain(self.filesys_extra.iter().cloned()) + .collect(); // CD raw.cd.image = self.cd_image.as_deref().map(path_string); if self.cd_insert_delay != 0.0 { @@ -1084,6 +1174,11 @@ impl MachineSetup { F::Df1Image | F::Df1WriteProtect => reason(self.floppy_drives >= 2, "drive off"), F::Df2Image | F::Df2WriteProtect => reason(self.floppy_drives >= 3, "drive off"), F::Df3Image | F::Df3WriteProtect => reason(self.floppy_drives >= 4, "drive off"), + // A boot priority is meaningless without a directory to boot. + F::Filesys0Boot | F::Filesys1Boot | F::Filesys2Boot | F::Filesys3Boot => { + let (slot, _) = filesys_slot(field).expect("boot field"); + reason(self.filesys_dirs[slot].is_some(), "no directory") + } #[cfg(feature = "midi")] F::MidiOut | F::MidiIn => reason(self.serial_mode == SerialMode::Midi, "MIDI off"), // Channel mode and separation shape the output, so they do nothing @@ -1139,6 +1234,10 @@ impl MachineSetup { F::ScsiUnit4 => self.scsi_units[4].as_deref(), F::ScsiUnit5 => self.scsi_units[5].as_deref(), F::ScsiUnit6 => self.scsi_units[6].as_deref(), + F::Filesys0Dir => self.filesys_dirs[0].as_deref(), + F::Filesys1Dir => self.filesys_dirs[1].as_deref(), + F::Filesys2Dir => self.filesys_dirs[2].as_deref(), + F::Filesys3Dir => self.filesys_dirs[3].as_deref(), F::CdImage => self.cd_image.as_deref(), F::Cd32Nvram => self.cd32_nvram.as_deref(), _ => None, @@ -1159,6 +1258,10 @@ impl MachineSetup { | F::ScsiUnit4 | F::ScsiUnit5 | F::ScsiUnit6 + | F::Filesys0Dir + | F::Filesys1Dir + | F::Filesys2Dir + | F::Filesys3Dir ) } @@ -1174,6 +1277,10 @@ impl MachineSetup { F::ScsiUnit4 => &self.scsi_unit_names[4], F::ScsiUnit5 => &self.scsi_unit_names[5], F::ScsiUnit6 => &self.scsi_unit_names[6], + F::Filesys0Dir => &self.filesys_names[0], + F::Filesys1Dir => &self.filesys_names[1], + F::Filesys2Dir => &self.filesys_names[2], + F::Filesys3Dir => &self.filesys_names[3], _ => return None, }; name.as_deref() @@ -1196,6 +1303,10 @@ impl MachineSetup { F::ScsiUnit4 => &mut self.scsi_unit_names[4], F::ScsiUnit5 => &mut self.scsi_unit_names[5], F::ScsiUnit6 => &mut self.scsi_unit_names[6], + F::Filesys0Dir => &mut self.filesys_names[0], + F::Filesys1Dir => &mut self.filesys_names[1], + F::Filesys2Dir => &mut self.filesys_names[2], + F::Filesys3Dir => &mut self.filesys_names[3], _ => return, }; *slot = value; @@ -1277,6 +1388,13 @@ impl MachineSetup { ChannelMode::Mono => "Mono".to_string(), }, F::AudioStereoSeparation => format!("{}%", self.audio_stereo_separation), + F::Filesys0Boot | F::Filesys1Boot | F::Filesys2Boot | F::Filesys3Boot => { + let (slot, _) = filesys_slot(field).expect("boot field"); + match self.filesys_bootpri[slot] { + -128 => "Never".to_string(), + pri => pri.to_string(), + } + } // Path/drive fields: the file name, or a placeholder. F::Rom => self.path_label(field, "(bundled AROS)"), _ if rows_contains_kind(field, RowKind::Path) @@ -1385,7 +1503,11 @@ impl MachineSetup { forward, ) as u8 } - _ => {} + _ => { + if let Some((slot, true)) = filesys_slot(field) { + self.filesys_bootpri[slot] = cycle_bootpri(self.filesys_bootpri[slot], forward); + } + } } } @@ -1431,7 +1553,11 @@ impl MachineSetup { F::ScsiUnit6 => self.scsi_units[6] = Some(path), F::CdImage => self.cd_image = Some(path), F::Cd32Nvram => self.cd32_nvram = Some(path), - _ => {} + _ => { + if let Some((slot, false)) = filesys_slot(field) { + self.filesys_dirs[slot] = Some(path); + } + } } } @@ -1463,7 +1589,14 @@ impl MachineSetup { F::ScsiUnit6 => self.scsi_units[6] = None, F::CdImage => self.cd_image = None, F::Cd32Nvram => self.cd32_nvram = None, - _ => {} + _ => { + if let Some((slot, false)) = filesys_slot(field) { + self.filesys_dirs[slot] = None; + // Boot priority on a mount with no directory is + // meaningless; reset it so a cleared slot emits nothing. + self.filesys_bootpri[slot] = -128; + } + } } // A drive's volume name is meaningless once its image is gone. if Self::is_drive_field(field) { @@ -1614,17 +1747,29 @@ impl LauncherState { } } - /// Commit the edit buffer to the focused field. + /// Commit the edit buffer to the focused field. A drive name that would + /// not survive the config validator keeps the field focused, so the name + /// can be fixed instead of failing later at save. pub fn edit_commit(&mut self) { - if let Some(target) = self.editing.take() { - let value = std::mem::take(&mut self.edit_buffer); - match target { - EditTarget::BoardOption { board, opt } => { - self.setup.zorro_option_set(board, opt, value); - } - EditTarget::DriveName(field) => self.setup.set_drive_name(field, value), + let Some(target) = self.editing else { return }; + if let EditTarget::DriveName(_) = target { + let name = self.edit_buffer.trim(); + let invalid = (!name.is_empty()) + .then(|| crate::filesys::volume_name_error(name)) + .flatten(); + if let Some(err) = invalid { + self.status = Some(StatusMessage::err(err)); + return; } } + self.editing = None; + let value = std::mem::take(&mut self.edit_buffer); + match target { + EditTarget::BoardOption { board, opt } => { + self.setup.zorro_option_set(board, opt, value) + } + EditTarget::DriveName(field) => self.setup.set_drive_name(field, value), + } } pub fn edit_cancel(&mut self) { @@ -1686,6 +1831,33 @@ fn drive_raw(path: Option<&Path>, name: Option<&str>) -> Option { }) } +/// Boot priorities offered on the Host FS boot-pri stepper. -128 is the +/// "never boot" sentinel; the rest bracket the usual device priorities +/// (hard-disk partitions boot at 0, DF0: at 5). +const BOOTPRI_STEPS: [i8; 8] = [-128, -10, -5, 0, 5, 6, 10, 20]; + +fn cycle_bootpri(current: i8, forward: bool) -> i8 { + let idx = BOOTPRI_STEPS + .iter() + .position(|&p| p == current) + .unwrap_or_else(|| { + // An off-list value (hand-edited config): snap to the nearest. + BOOTPRI_STEPS + .iter() + .enumerate() + .min_by_key(|(_, &p)| (i32::from(p) - i32::from(current)).abs()) + .map(|(i, _)| i) + .unwrap_or(0) + }); + let n = BOOTPRI_STEPS.len(); + let next = if forward { + (idx + 1) % n + } else { + (idx + n - 1) % n + }; + BOOTPRI_STEPS[next] +} + fn cycle_slice(items: &[T], current: T, forward: bool) -> T { let n = items.len(); let idx = items.iter().position(|&x| x == current).unwrap_or(0); @@ -1953,6 +2125,77 @@ mod tests { let _ = std::fs::remove_file(&path); } + fn raw_mount(path: &str) -> RawFilesysMount { + RawFilesysMount { + path: path.to_string(), + volume: Some(path.trim_start_matches('/').to_uppercase()), + bootpri: None, + } + } + + #[test] + fn host_mounts_round_trip_and_keep_entries_past_the_gui_slots() { + let raw = RawConfig { + filesys: (0..6).map(|i| raw_mount(&format!("/host{i}"))).collect(), + ..RawConfig::default() + }; + + let mut setup = MachineSetup::from_raw(&raw).unwrap(); + // The GUI edits the first FILESYS_GUI_SLOTS mounts; the rest are held + // verbatim so a save never drops a hand-written entry. + assert_eq!(setup.filesys_dirs[0], Some(PathBuf::from("/host0"))); + assert_eq!(setup.filesys_dirs[3], Some(PathBuf::from("/host3"))); + assert_eq!(setup.filesys_extra.len(), 2); + + // An untouched save is a faithful round trip. + assert_eq!(setup.to_raw().filesys, raw.filesys); + + // Clearing a slot removes that mount. HOSTFS is the position in the + // config, so the mounts after it renumber, exactly as they would if the + // entry were deleted from the TOML by hand. + setup.filesys_dirs[1] = None; + setup.filesys_names[1] = None; + let saved = setup.to_raw().filesys; + let paths: Vec<&str> = saved.iter().map(|m| m.path.as_str()).collect(); + assert_eq!(paths, ["/host0", "/host2", "/host3", "/host4", "/host5"]); + + // The formerly-extra mounts now fall inside the GUI slots, and the + // volume names still travel with their own paths. + let back = MachineSetup::from_raw(&RawConfig { + filesys: saved, + ..RawConfig::default() + }) + .unwrap(); + assert_eq!(back.filesys_dirs[1], Some(PathBuf::from("/host2"))); + assert_eq!(back.filesys_names[1].as_deref(), Some("HOST2")); + assert_eq!(back.filesys_extra.len(), 1); + } + + #[test] + fn an_invalid_drive_name_is_reported_and_keeps_the_field_focused() { + let mut state = LauncherState::from_raw(&RawConfig { + filesys: vec![raw_mount("/host0")], + ..RawConfig::default() + }); + state.begin_edit_drive_name(LauncherField::Filesys0Dir); + state.edit_buffer.clear(); + for c in "Work:1".chars() { + state.edit_push(c); + } + state.edit_commit(); + let status = state.status.as_ref().expect("invalid name is reported"); + assert!(status.error); + assert!(status.text.contains("invalid character"), "{}", status.text); + assert_eq!(state.editing(), Some(EditTarget::DriveName(F::Filesys0Dir))); + + // Fixing the name commits it. + state.edit_backspace(); + state.edit_backspace(); + state.edit_commit(); + assert!(state.editing().is_none()); + assert_eq!(state.setup.drive_name(F::Filesys0Dir), Some("Work")); + } + #[test] fn default_setup_is_the_a500_aros_machine() { let s = MachineSetup::default(); diff --git a/src/video/ui.rs b/src/video/ui.rs index e09f538..a3fe1ba 100644 --- a/src/video/ui.rs +++ b/src/video/ui.rs @@ -3595,6 +3595,24 @@ fn truncate_to_width(text: &str, avail_px: usize) -> String { format!("{kept}~") } +/// Clip a path to `avail_px`, keeping the TAIL and prefixing an ASCII "..." +/// when it does not fit -- for a host directory the meaningful end (the leaf +/// dir) stays visible. The bitmap font is ASCII-only, so a real ellipsis +/// glyph cannot be drawn; "..." is the closest it can render. Mirrors +/// [`truncate_to_width`], which keeps the head instead. +fn clip_path_tail(text: &str, avail_px: usize) -> String { + let max_chars = avail_px / font::GLYPH_W; + let len = text.chars().count(); + if len <= max_chars { + return text.to_string(); + } + if max_chars <= 3 { + return ".".repeat(max_chars); + } + let tail: String = text.chars().skip(len - (max_chars - 3)).collect(); + format!("...{tail}") +} + /// A model-selector / tab button: a flat bevel that fills with the title-bar /// blue when active/selected. Tabs label left, model buttons centred. fn draw_launcher_chip( @@ -3762,7 +3780,13 @@ fn draw_launcher_row( let name_box = launcher_drive_name_rect(rect, row_y); let text_right = if has_image { name_box.x } else { browse.x }; let avail = text_right.saturating_sub(value_x + 8); - let text = truncate_to_width(&setup.value_label(r.field), avail); + // Host FS mounts show the whole host path (tail-clipped with a + // leading "..." when long), since the full path is meaningful; + // other drives show the image's file name. + let text = match (r.field.is_filesys_dir_field(), setup.path(r.field)) { + (true, Some(p)) => clip_path_tail(&p.to_string_lossy(), avail), + _ => truncate_to_width(&setup.value_label(r.field), avail), + }; draw_panel_text(frame, value_x, browse.y + 6, &text, PANEL_TEXT, 1, scale); if has_image { draw_rect_bevel( diff --git a/src/video/window.rs b/src/video/window.rs index 80eacb0..0f1227b 100644 --- a/src/video/window.rs +++ b/src/video/window.rs @@ -1397,6 +1397,7 @@ impl ApplicationHandler for App { state, physical_key: PhysicalKey::Code(code), repeat, + text, .. }, .. @@ -1494,7 +1495,7 @@ impl ApplicationHandler for App { } (other, state) => { let pressed = state == ElementState::Pressed; - if pressed && self.ui_handle_key(other) { + if pressed && self.ui_handle_key(other, text.as_deref()) { return; } // Open panels are modal: key presses must not leak @@ -3052,7 +3053,7 @@ impl App { /// Keys consumed by the open menu/panel (Escape, debugger hex entry). /// Returns true when the key was handled and must not reach the Amiga. - fn ui_handle_key(&mut self, code: KeyCode) -> bool { + fn ui_handle_key(&mut self, code: KeyCode, text: Option<&str>) -> bool { if self.ui.active() { if code == KeyCode::Escape { // While typing into a plugin option, Escape cancels the edit @@ -3069,7 +3070,7 @@ impl App { return true; } // Route keys to a focused plugin-option text field, if any. - if self.launcher_handle_edit_key(code) { + if self.launcher_handle_edit_key(code, text) { return true; } return false; @@ -3095,7 +3096,7 @@ impl App { /// Feed a key to a focused plugin-option text field. Returns false (so the /// key falls through) when no field is being edited. - fn launcher_handle_edit_key(&mut self, code: KeyCode) -> bool { + fn launcher_handle_edit_key(&mut self, code: KeyCode, text: Option<&str>) -> bool { let handled = { let Some(state) = self.launcher_state_mut() else { return false; @@ -3103,14 +3104,22 @@ impl App { if state.editing().is_none() { return false; } - if let Some(ch) = entry_char_for_key(code) { - state.edit_push(ch); - } else { - match code { - KeyCode::Backspace => state.edit_backspace(), - KeyCode::Enter | KeyCode::NumpadEnter => state.edit_commit(), - // Swallow other keys while a field has focus. - _ => {} + match code { + KeyCode::Backspace => state.edit_backspace(), + KeyCode::Enter | KeyCode::NumpadEnter => state.edit_commit(), + _ => { + // Prefer the layout- and shift-aware text the platform + // reports, so volume names can contain lowercase letters, + // underscores, and other printable characters (the + // keycode map is uppercase-only and lacks symbols). Fall + // back to it only when no text is delivered. + if let Some(t) = text.filter(|t| !t.is_empty()) { + for ch in t.chars().filter(|c| !c.is_control()) { + state.edit_push(ch); + } + } else if let Some(ch) = entry_char_for_key(code) { + state.edit_push(ch); + } } } true @@ -3689,6 +3698,12 @@ impl App { /// Open a native file dialog for a configuration-screen path field, seeded /// at the field's current directory, and store the picked path. fn launcher_browse(&mut self, field: LauncherField) { + // Host FS mounts are a host directory, not an image file, so they get + // a folder picker seeded at the current directory itself. + if LauncherField::is_filesys_dir_field(field) { + self.launcher_browse_folder(field); + return; + } let start_dir = self .launcher_state() .and_then(|s| s.setup.path(field)) @@ -3727,6 +3742,28 @@ impl App { self.finish_host_io_pause(); } + /// Folder picker for a Host FS mount's directory field. + fn launcher_browse_folder(&mut self, field: LauncherField) { + let start_dir = self + .launcher_state() + .and_then(|s| s.setup.path(field)) + .map(|p| p.to_path_buf()); + self.suspend_live_audio_for_host_io(); + let mut dialog = rfd::FileDialog::new().set_title("Select host directory"); + if let Some(dir) = start_dir { + dialog = dialog.set_directory(dir); + } + let picked = dialog.pick_folder(); + if let Some(path) = picked { + if let Some(state) = self.launcher_state_mut() { + state.edit_cancel(); + state.setup.set_path(field, path); + state.status = None; + } + } + self.finish_host_io_pause(); + } + fn launcher_add_zorro(&mut self) { self.suspend_live_audio_for_host_io(); let picked = rfd::FileDialog::new() diff --git a/src/video/window/tests.rs b/src/video/window/tests.rs index 4fa3d3d..8b67416 100644 --- a/src/video/window/tests.rs +++ b/src/video/window/tests.rs @@ -2367,7 +2367,7 @@ fn modal_panel_swallows_amiga_key_presses() { app.ui.panel = Some(Panel::About); // Escape closes the panel. - assert!(app.ui_handle_key(KeyCode::Escape)); + assert!(app.ui_handle_key(KeyCode::Escape, None)); assert!(app.ui.panel.is_none()); // Hex entry: digits accumulate, Enter commits to the memory view. @@ -2382,9 +2382,9 @@ fn modal_panel_swallows_amiga_key_presses() { KeyCode::Digit0, KeyCode::Digit1, ] { - assert!(app.ui_handle_key(key)); + assert!(app.ui_handle_key(key, None)); } - assert!(app.ui_handle_key(KeyCode::Enter)); + assert!(app.ui_handle_key(KeyCode::Enter, None)); match app.debugger_panel.as_ref() { Some(panel) => { assert_eq!(panel.entry, "C001"); @@ -2409,8 +2409,8 @@ fn frame_analyzer_cursor_keys_move_selected_slot() { _ => panic!("frame analyzer panel should be open"), }; - assert!(app.ui_handle_key(KeyCode::ArrowRight)); - assert!(app.ui_handle_key(KeyCode::ArrowDown)); + assert!(app.ui_handle_key(KeyCode::ArrowRight, None)); + assert!(app.ui_handle_key(KeyCode::ArrowDown, None)); match app.frame_analyzer_panel.as_ref() { Some(panel) => { assert_eq!(panel.selected_hpos, start_hpos + 1); @@ -2419,8 +2419,8 @@ fn frame_analyzer_cursor_keys_move_selected_slot() { _ => panic!("frame analyzer panel should be open"), } - assert!(app.ui_handle_key(KeyCode::ArrowLeft)); - assert!(app.ui_handle_key(KeyCode::ArrowUp)); + assert!(app.ui_handle_key(KeyCode::ArrowLeft, None)); + assert!(app.ui_handle_key(KeyCode::ArrowUp, None)); match app.frame_analyzer_panel.as_ref() { Some(panel) => { assert_eq!(panel.selected_hpos, start_hpos); @@ -2433,8 +2433,8 @@ fn frame_analyzer_cursor_keys_move_selected_slot() { panel.selected_hpos = 0; panel.selected_vpos = 0; } - assert!(app.ui_handle_key(KeyCode::ArrowLeft)); - assert!(app.ui_handle_key(KeyCode::ArrowUp)); + assert!(app.ui_handle_key(KeyCode::ArrowLeft, None)); + assert!(app.ui_handle_key(KeyCode::ArrowUp, None)); match app.frame_analyzer_panel.as_ref() { Some(panel) => { assert_eq!(panel.selected_hpos, 0); @@ -2458,8 +2458,8 @@ fn frame_analyzer_cursor_keys_move_selected_slot() { panel.selected_hpos = max_hpos; panel.selected_vpos = max_vpos; } - assert!(app.ui_handle_key(KeyCode::ArrowRight)); - assert!(app.ui_handle_key(KeyCode::ArrowDown)); + assert!(app.ui_handle_key(KeyCode::ArrowRight, None)); + assert!(app.ui_handle_key(KeyCode::ArrowDown, None)); match app.frame_analyzer_panel.as_ref() { Some(panel) => { assert_eq!(panel.selected_hpos, max_hpos); @@ -2483,7 +2483,7 @@ fn frame_analyzer_underlay_toggles_and_renders() { assert!(app.build_frame_analyzer_view(&panel).underlay.is_none()); // The U key ticks the checkbox on. - assert!(app.ui_handle_key(KeyCode::KeyU)); + assert!(app.ui_handle_key(KeyCode::KeyU, None)); assert!(app .frame_analyzer_panel .as_ref() @@ -3336,14 +3336,14 @@ fn debugger_keys_step_and_pin_disassembly() { // S steps one instruction while the entry box is unfocused. let pc_before = app.emu.machine.pc(); - assert!(app.ui_handle_key(KeyCode::KeyS)); + assert!(app.ui_handle_key(KeyCode::KeyS, None)); assert_eq!(app.emu.machine.pc(), pc_before.wrapping_add(2)); // R toggles run; the explicit choice survives closing the panel. assert!(app.paused); - assert!(app.ui_handle_key(KeyCode::KeyR)); + assert!(app.ui_handle_key(KeyCode::KeyR, None)); assert!(!app.paused); - assert!(app.ui_handle_key(KeyCode::KeyR)); + assert!(app.ui_handle_key(KeyCode::KeyR, None)); assert!(app.paused); // On the CPU tab, Enter pins the disassembly origin to the typed @@ -3352,7 +3352,7 @@ fn debugger_keys_step_and_pin_disassembly() { panel.entry_active = true; panel.entry = "FC0010".to_string(); } - assert!(app.ui_handle_key(KeyCode::Enter)); + assert!(app.ui_handle_key(KeyCode::Enter, None)); match app.debugger_panel.as_ref() { Some(panel) => { assert_eq!(panel.disasm_addr, Some(0xFC0010)); @@ -3366,7 +3366,7 @@ fn debugger_keys_step_and_pin_disassembly() { panel.entry_active = true; panel.entry.clear(); } - assert!(app.ui_handle_key(KeyCode::Enter)); + assert!(app.ui_handle_key(KeyCode::Enter, None)); match app.debugger_panel.as_ref() { Some(panel) => assert_eq!(panel.disasm_addr, None), _ => panic!("debugger panel should be open"), @@ -3379,7 +3379,7 @@ fn debugger_keys_step_and_pin_disassembly() { panel.entry.clear(); } let pc_before = app.emu.machine.pc(); - assert!(app.ui_handle_key(KeyCode::KeyS)); + assert!(app.ui_handle_key(KeyCode::KeyS, None)); assert_eq!(app.emu.machine.pc(), pc_before); assert_eq!( app.debugger_panel.as_ref().map(|p| p.entry.as_str()),