From b43b18901d6375693093192c038903cf263d329c Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Wed, 15 Jul 2026 08:16:51 +0100 Subject: [PATCH] ui: drag and drop disk images onto the window (desktop and browser) Dropped floppy images insert directly when one drive is connected; with several, an Insert Disk chooser panel opens (click, 1-4, Esc), since winit 0.30 reports file drops without a cursor position on every platform, so drop-on-drive-icon targeting is not possible until the 0.31 positional drag events ship. Multiple floppies become the target drive's swap playlist like a multi-selection in the load dialog; .cue mounts in the CD drive; .hdf/.rom point at the configuration screen, which also refuses drops while open. A dimmed hint overlay shows while a drag hovers, and per-file DroppedFile events coalesce into one action in about_to_wait. The browser page routes drops through the existing picker paths: .rom loads (or queues) a Kickstart, anything else inserts into DF0, with the same pre-boot stashing and 64 MiB cap; the hint overlay is built from JS so the page shell needs no changes. The dialog and drop flows share new insert_disk_playlist and insert_cd_image_from_path helpers, and FloppyController gained inserted_disk_name for the chooser labels. Closes #189 --- crates/copperline-web/www/try.js | 92 +++++++++++ docs/guide/browser.md | 6 + docs/guide/ui.md | 21 +++ src/floppy.rs | 14 ++ src/video/ui.rs | 221 ++++++++++++++++++++++++++ src/video/window.rs | 260 +++++++++++++++++++++++++++---- src/video/window/tests.rs | 171 ++++++++++++++++++++ 7 files changed, 758 insertions(+), 27 deletions(-) diff --git a/crates/copperline-web/www/try.js b/crates/copperline-web/www/try.js index 90022ef3..9212a033 100644 --- a/crates/copperline-web/www/try.js +++ b/crates/copperline-web/www/try.js @@ -821,6 +821,98 @@ $('df0url')?.addEventListener('click', () => { if (url && url.trim()) insertDiskFromUrl(url.trim()); }); +// --- drag and drop --------------------------------------------------------- +// Files dropped anywhere on the page route like the pickers: a .rom loads +// (or queues) a Kickstart, anything else inserts into DF0. The hint overlay +// is built here rather than in the page shell (index.html lives in the +// website repository and is left alone). + +let dropHint = null; // built lazily, like the fullscreen UI +let dragDepth = 0; // dragenter/dragleave fire per element crossed + +function ensureDropHint() { + if (dropHint) return dropHint; + dropHint = document.createElement('div'); + dropHint.style.cssText = + 'position:absolute;inset:0;z-index:4;display:none;' + + 'align-items:center;justify-content:center;text-align:center;' + + 'pointer-events:none;background:rgba(10,13,22,0.7);' + + 'border:2px dashed rgba(255,255,255,0.5);' + + 'color:rgba(255,255,255,0.9);padding:1rem;' + + 'font:600 1rem "IBM Plex Mono",ui-monospace,monospace;'; + dropHint.textContent = 'Drop: disk image -> DF0, .rom -> Kickstart'; + shell.appendChild(dropHint); + return dropHint; +} + +function showDropHint(on) { + ensureDropHint().style.display = on ? 'flex' : 'none'; +} + +async function handleDroppedFiles(files) { + const list = Array.from(files ?? []); + if (!list.length) return; + const oversize = list.find((f) => f.size > DISK_URL_MAX_BYTES); + if (oversize) { + setLoadStatus(`${oversize.name}: file too large`); + return; + } + // One drive and one ROM socket: the first of each kind wins, extras + // are ignored. + const rom = list.find((f) => /\.rom$/i.test(f.name)); + const disk = list.find((f) => !/\.rom$/i.test(f.name)); + try { + if (rom) { + const bytes = new Uint8Array(await rom.arrayBuffer()); + if (emu) { + emu.load_rom(bytes, undefined); + setLoadStatus(`Kickstart loaded: ${rom.name} - machine power-cycled`); + } else { + bootRom = { rom: bytes, ext: null, label: rom.name }; + refreshBootButton(); + setLoadStatus(`will boot ${rom.name}`); + } + } + if (disk) { + const bytes = new Uint8Array(await disk.arrayBuffer()); + if (emu) { + emu.insert_floppy(0, bytes, disk.name); + setLoadStatus(`DF0: ${disk.name} (write-protected)`); + } else { + pendingDisk = { bytes, name: disk.name }; + setLoadStatus(`DF0: ${disk.name} (inserts at boot)`); + } + } + } catch (err) { + setLoadStatus(`drop failed: ${err.message ?? err}`); + } +} + +// Document-level handlers so a missed drop never navigates the page away +// to the dropped file. +document.addEventListener('dragenter', (e) => { + if (!e.dataTransfer?.types?.includes('Files')) return; + e.preventDefault(); + dragDepth += 1; + showDropHint(true); +}); +document.addEventListener('dragover', (e) => { + if (!e.dataTransfer?.types?.includes('Files')) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; +}); +document.addEventListener('dragleave', () => { + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) showDropHint(false); +}); +document.addEventListener('drop', (e) => { + dragDepth = 0; + showDropHint(false); + if (!e.dataTransfer) return; + e.preventDefault(); + handleDroppedFiles(e.dataTransfer.files); +}); + bootBtn.addEventListener('click', boot); const linkedDisk = new URLSearchParams(location.search).get('df0'); if (linkedDisk) insertDiskFromUrl(linkedDisk); diff --git a/docs/guide/browser.md b/docs/guide/browser.md index 8b38fcff..b1aaee8b 100644 --- a/docs/guide/browser.md +++ b/docs/guide/browser.md @@ -22,6 +22,12 @@ offer every file rather than filtering by extension, because the system document picker greys out extensions it does not recognise, which would lock out `.adf` and friends. +Files can also be dragged onto the page: a `.rom` file loads (or, before +boot, queues) a Kickstart exactly like the ROM picker, and anything else +inserts into DF0 like the disk picker -- dropped before boot it queues and +inserts when the machine starts. The same 64 MiB cap as URL fetches +applies. + A disk can also come from a link: `/try/?df0=` fetches the image while the emulator loads and inserts it at boot, so a bootable demo is one shareable URL, and the **DF0 from URL** button does the same for a pasted diff --git a/docs/guide/ui.md b/docs/guide/ui.md index 18bc1fa0..e4606bf3 100644 --- a/docs/guide/ui.md +++ b/docs/guide/ui.md @@ -71,6 +71,27 @@ The status bar (44 pixels below the display) holds, left to right: powered; power cold-boots (clears RAM) or powers off back to the test screen; reboot is a warm reset. +## Drag and drop + +Disk images can be dropped anywhere on the emulator window: + +- **Floppy images** (ADF/ADZ/DMS/SCP, gzip or zip packed): with one + connected drive the disk is inserted immediately. With several, a drive + chooser opens over the display -- click a drive, press its number + (`1`-`4`), or press `Esc` to cancel. Dropping several floppies at once + queues them all as the target drive's swap playlist, exactly like a + multi-selection in the disk dialog. +- **Cue sheets** (`.cue`) mount in the CD drive on CDTV/CD32 machines, + with the media-change notification. +- **Hard disk images and Kickstart ROMs** cannot be swapped at runtime; a + notice points at the machine-configuration screen, which also refuses + drops while it is open. + +The chooser opens after the drop rather than offering per-drive drop +targets because the windowing layer reports file drops without a cursor +position. For the same reason drops are unavailable under native Wayland +(X11/XWayland works). + ## Menu, tool windows, and overlay panels ```{figure} ../images/ui-preview-menu.png diff --git a/src/floppy.rs b/src/floppy.rs index b82a52a2..8fc2bf2d 100644 --- a/src/floppy.rs +++ b/src/floppy.rs @@ -341,6 +341,20 @@ impl FloppyController { .is_some_and(|drive| drive.image.is_some()) } + /// File name of the inserted image, for UI labels. Read from the image + /// itself rather than any host-side playlist, so CLI and config-embedded + /// inserts are covered too. + pub fn inserted_disk_name(&self, drive_idx: usize) -> Option { + let image = self.drives.get(drive_idx)?.image.as_ref()?; + Some( + image + .path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| image.path.display().to_string()), + ) + } + pub fn insert_disk_image( &mut self, drive_idx: usize, diff --git a/src/video/ui.rs b/src/video/ui.rs index a3fe1ba9..e9f2afdc 100644 --- a/src/video/ui.rs +++ b/src/video/ui.rs @@ -480,6 +480,25 @@ impl ConsolePanel { } } +/// One drive target offered by the drop chooser. +pub struct DropDriveEntry { + pub drive: usize, + /// Ready-made button label, e.g. "DF0: workbench.adf" or "DF1 (empty)". + pub label: String, +} + +/// State of the dropped-disk drive chooser. Everything is snapshotted at +/// open time: the panel is modal, so the drive labels cannot change under +/// it, and no per-frame view data is needed. +pub struct DropChooserState { + /// The dropped image paths; all become the chosen drive's swap playlist. + pub disks: Vec, + /// Header line naming what is being inserted (first file's name). + pub disk_label: String, + /// One entry per connected drive, in DF order. + pub drives: Vec, +} + /// An open overlay sub-window. pub enum Panel { About, @@ -491,6 +510,10 @@ pub enum Panel { /// The pre-boot machine-configuration screen. Boxed: its state is far /// larger than the other variants. Launcher(Box), + /// Drive chooser for dropped disk images: winit reports file drops + /// with no cursor position, so with several connected drives the drop + /// lands anywhere on the window and the target is picked here. + DropChooser(DropChooserState), } /// Menu/panel state owned by the window. @@ -611,6 +634,13 @@ pub fn panel_control_at(panel: &Panel, pos: (i32, i32)) -> Option { return Some(control); } } + Panel::DropChooser(state) => { + for (control, button_rect) in drop_chooser_button_rects(rect, state) { + if button_rect.contains(pos) { + return Some(control); + } + } + } Panel::About | Panel::Shortcuts => {} } rect.contains(pos).then_some(UiControl::PanelBody) @@ -765,6 +795,8 @@ pub enum UiControl { LauncherDefaults, /// Configuration screen: build and run the configured machine. LauncherRun, + /// Drop chooser: insert the dropped disk(s) into this drive. + DropDrive(usize), } fn panel_dims(panel: &Panel) -> (usize, usize) { @@ -776,6 +808,13 @@ fn panel_dims(panel: &Panel) -> (usize, usize) { Panel::FrameAnalyzer(_) => (700, 526), Panel::Console(_) => (700, 460), Panel::Launcher(_) => (LAUNCHER_W, LAUNCHER_H), + Panel::DropChooser(state) => ( + 460, + TITLE_H + + DROP_HEADER_H + + state.drives.len() * (DROP_BUTTON_H + DROP_BUTTON_GAP) + + DROP_FOOTER_H, + ), } } @@ -788,6 +827,7 @@ fn panel_title(panel: &Panel) -> &'static str { Panel::FrameAnalyzer(_) => "Frame Analyzer", Panel::Console(_) => "Console", Panel::Launcher(_) => "Machine Configuration", + Panel::DropChooser(_) => "Insert Disk", } } @@ -839,6 +879,32 @@ fn cal_button_enabled(control: UiControl, session: &crate::gamepad::CalibrationS } } +// Drop chooser: a header naming the dropped disk, then one large target +// button per connected drive, and a key-hint footer. +const DROP_BUTTON_H: usize = 30; +const DROP_BUTTON_GAP: usize = 8; +const DROP_HEADER_H: usize = 46; +const DROP_FOOTER_H: usize = 24; + +fn drop_chooser_button_rects(rect: Rect, state: &DropChooserState) -> Vec<(UiControl, Rect)> { + state + .drives + .iter() + .enumerate() + .map(|(index, entry)| { + ( + UiControl::DropDrive(entry.drive), + Rect { + x: rect.x + 16, + y: rect.y + TITLE_H + DROP_HEADER_H + index * (DROP_BUTTON_H + DROP_BUTTON_GAP), + w: rect.w - 32, + h: DROP_BUTTON_H, + }, + ) + }) + .collect() +} + // Debugger chrome: a tab row under the title and a control row at the // bottom with the transport buttons and the shared hex-entry box. const DEBUG_TAB_W: usize = 80; @@ -1653,6 +1719,92 @@ fn draw_about(frame: &mut [u8], rect: Rect, view: &AboutView, scale: usize) { } } +fn draw_drop_chooser( + frame: &mut [u8], + rect: Rect, + state: &DropChooserState, + hover: Option, + scale: usize, +) { + // The title bar carries the verb ("Insert Disk"); the header just + // names the image, truncated to the panel width. + let max_chars = (rect.w - 32) / 16; + let mut header = state.disk_label.clone(); + if header.chars().count() > max_chars { + header = header.chars().take(max_chars.saturating_sub(2)).collect(); + header.push_str(".."); + } + let mut y = rect.y + TITLE_H + 10; + draw_panel_text(frame, rect.x + 16, y, &header, PANEL_TEXT, 2, scale); + y += 20; + if state.disks.len() > 1 { + let note = format!( + "{} disks: extras queue as the drive's swap playlist", + state.disks.len() + ); + draw_panel_text(frame, rect.x + 16, y, ¬e, PANEL_TEXT_DIM, 1, scale); + } + for (index, (control, button_rect)) in drop_chooser_button_rects(rect, state) + .into_iter() + .enumerate() + { + let mut label = format!("{} {}", index + 1, state.drives[index].label); + // draw_text_button does not clip; keep long disk names inside. + let max_label_chars = button_rect.w.saturating_sub(8) / font::GLYPH_W; + if label.chars().count() > max_label_chars { + label = label + .chars() + .take(max_label_chars.saturating_sub(2)) + .collect(); + label.push_str(".."); + } + draw_text_button( + frame, + button_rect, + &label, + true, + hover == Some(control), + scale, + ); + } + let hint = format!("1-{} selects - Esc cancels", state.drives.len()); + draw_panel_text( + frame, + rect.x + 16, + rect.y + rect.h - DROP_FOOTER_H + 6, + &hint, + PANEL_TEXT_DIM, + 1, + scale, + ); +} + +/// Full-display hint drawn while files hover over the window in a drag. +/// Not a Panel: it must not gate input, and winit reports no positions +/// during a file drag, so it can only announce that a drop will land. +pub fn draw_drop_hint(frame: &mut [u8], texture_scale: usize) { + fill_rect_blend( + frame, + scale_rect( + Rect { + x: 0, + y: 0, + w: FB_WIDTH, + h: present_height(), + }, + texture_scale, + ), + SCRIM, + SCRIM_ALPHA, + texture_scale, + ); + let text = "Drop disk image to insert"; + let px = 2; + let x = FB_WIDTH.saturating_sub(text.len() * 8 * px) / 2; + let y = present_height() / 2 - 8; + draw_panel_text(frame, x, y, text, PANEL_TEXT_HILIGHT, px, texture_scale); +} + const SHORTCUT_ROWS: [(&str, &str, bool); 16] = [ ("Q", "Quit", true), ("S", "Save screenshot", true), @@ -4156,6 +4308,9 @@ pub fn draw_panel_layer( // view-data snapshot. (Panel::Console(panel_state), _) => draw_console(frame, rect, panel_state, texture_scale), (Panel::Launcher(state), _) => draw_launcher(frame, rect, state, hover, texture_scale), + (Panel::DropChooser(state), _) => { + draw_drop_chooser(frame, rect, state, hover, texture_scale) + } _ => {} } } @@ -5072,6 +5227,72 @@ mod tests { assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); save(&frame, "shortcuts"); + let mut frame = vec![0u8; w * h * 4]; + let ui = UiState { + menu_open: false, + panel: Some(Panel::DropChooser(DropChooserState { + disks: vec![ + std::path::PathBuf::from("turrican2-disk1.adf"), + std::path::PathBuf::from("turrican2-disk2.adf"), + ], + disk_label: "turrican2-disk1.adf".to_string(), + drives: vec![ + DropDriveEntry { + drive: 0, + label: "DF0: workbench.adf".to_string(), + }, + DropDriveEntry { + drive: 1, + label: "DF1 (empty)".to_string(), + }, + ], + })), + }; + draw( + &mut frame, + scale, + &ui, + Some(UiControl::DropDrive(1)), + None, + false, + MenuLabels { + warp: false, + warp_speed: WarpSpeed::Max, + recording: false, + input_recording: false, + joystick_input_mode: JoystickInputMode::Gamepad, + pixel_aspect: PixelAspect::Tv, + midi_in: "", + midi_out: "", + audio_output: "", + }, + ); + assert!(panel_has_title_bar(&frame, ui.panel.as_ref().unwrap())); + // The hovered drive button renders inside the panel rect. + let panel = ui.panel.as_ref().unwrap(); + if let Panel::DropChooser(state) = panel { + let rect = panel_rect(panel); + let buttons = drop_chooser_button_rects(rect, state); + assert_eq!(buttons.len(), 2); + assert_eq!(buttons[1].0, UiControl::DropDrive(1)); + let button = buttons[1].1; + assert!(button.x >= rect.x && button.x + button.w <= rect.x + rect.w); + assert!(button.y >= rect.y && button.y + button.h <= rect.y + rect.h); + let probe = ((button.y + 2) * w + button.x + 2) * 4; + assert_eq!(&frame[probe..probe + 4], &BUTTON_FACE_HOVER.to_le_bytes()); + } else { + unreachable!(); + } + save(&frame, "drop-chooser"); + + // The pre-drop hover hint dims the display without opening a panel. + let mut frame = vec![0xFFu8; w * h * 4]; + draw_drop_hint(&mut frame, scale); + // The scrim darkens the display area but not the status bar below. + assert!(frame[0] < 0xFF); + assert_eq!(frame[present_height() * w * 4], 0xFF); + save(&frame, "drop-hint"); + let mut frame = vec![0u8; w * h * 4]; let session = crate::gamepad::CalibrationSession::new(); let rows = (0..crate::gamepad::CalibrationSession::step_count()) diff --git a/src/video/window.rs b/src/video/window.rs index 5e765d08..15c91508 100644 --- a/src/video/window.rs +++ b/src/video/window.rs @@ -573,6 +573,13 @@ pub struct App { /// Transient on-screen overlay message (screenshot saved, disk /// swapped), or None when nothing is being shown. osd: Option, + /// True while a file drag hovers over the main window; draws the + /// drop-hint overlay. winit sends no HoveredFileCancelled after a + /// successful drop, so DroppedFile clears this too. + drop_hover: bool, + /// Files from DroppedFile events, coalesced in about_to_wait: winit + /// delivers one event per file, and a multi-file drop must act once. + pending_dropped_files: Vec, /// Per-drive disk-swap playlists: the ordered image paths the user can /// cycle through for each drive with the disk-swap shortcut. Lets a /// multi-disk demo run on a single drive. @@ -938,6 +945,8 @@ impl App { render_generation: 0, last_fdd_track: None, osd: None, + drop_hover: false, + pending_dropped_files: Vec::new(), disk_playlists, disk_write_protected, disk_playlist_index: [0; 4], @@ -1551,6 +1560,25 @@ impl ApplicationHandler for App { self.request_redraw(); } } + WindowEvent::HoveredFile(_) => { + // One event per hovered file; flag once and redraw once. + if !self.drop_hover { + self.drop_hover = true; + self.request_redraw(); + } + } + WindowEvent::HoveredFileCancelled => { + self.drop_hover = false; + self.request_redraw(); + } + WindowEvent::DroppedFile(path) => { + // No HoveredFileCancelled follows a successful drop, so the + // hint is cleared here. One event arrives per dropped file; + // they are coalesced into a single action in about_to_wait. + self.drop_hover = false; + self.pending_dropped_files.push(path); + self.request_redraw(); + } WindowEvent::Focused(focused) => { self.main_window_focused = focused; if !focused { @@ -1755,6 +1783,12 @@ impl ApplicationHandler for App { audio_output: self.audio_output.label(), }, ); + // The drag hint sits on top of everything: the drop will + // land wherever the drag is released, panels or not. The + // launcher refuses drops, so no hint over it. + if self.drop_hover && !matches!(self.ui.panel, Some(Panel::Launcher(_))) { + ui::draw_drop_hint(frame, r.texture_scale); + } if let Err(e) = r.pixels.render() { error!("pixels.render: {e}"); } @@ -1785,6 +1819,12 @@ impl ApplicationHandler for App { if self.render.is_none() { return; } + // Act on a completed drop before the OSD/control-flow computation + // below, so a drop-raised OSD keeps the loop awake for its fade. + if !self.pending_dropped_files.is_empty() { + let files = std::mem::take(&mut self.pending_dropped_files); + self.handle_dropped_files(files); + } let running = self.powered_on && !self.cpu_halted && !self.paused; // While a transient overlay is up, keep the loop awake (and, when // the machine is paused/off, request repaints) so the message @@ -2046,6 +2086,35 @@ fn display_file_name(path: &std::path::Path) -> String { .unwrap_or_else(|| path.display().to_string()) } +/// What a file dropped on the window should be treated as. Extension-based: +/// only floppies get content-sniffed (by the insert path itself), and cue +/// sheets/hard disks/ROMs have no shared magic worth probing here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DroppedMediaKind { + /// Anything the floppy loader may accept (adf/adz/dms/scp/gz/zip and + /// unknown extensions): FloppyImage::from_bytes sniffs the content and + /// rejects what it cannot read, surfacing a clean OSD failure. + Floppy, + /// A cue sheet for the CD drive (runtime CD loading is cue-only). + CdCue, + /// Hard disk images cannot be hot-attached; point at the config screen. + HardDisk, + /// Kickstart ROMs load from the config screen, not at runtime. + Rom, +} + +fn classify_dropped_media(path: &std::path::Path) -> DroppedMediaKind { + let ext = path + .extension() + .map(|e| e.to_string_lossy().to_ascii_lowercase()); + match ext.as_deref() { + Some("cue") => DroppedMediaKind::CdCue, + Some("hdf") | Some("img") => DroppedMediaKind::HardDisk, + Some("rom") => DroppedMediaKind::Rom, + _ => DroppedMediaKind::Floppy, + } +} + /// A one-line, length-bounded form of an error for the configuration panel's /// status line (the full chain still goes to the log). fn short_status_error(err: &anyhow::Error) -> String { @@ -2932,6 +3001,7 @@ impl App { UiControl::LauncherLoad => self.launcher_load(), UiControl::LauncherSave => self.launcher_save(), UiControl::LauncherRun => self.launcher_run(), + UiControl::DropDrive(drive_idx) => self.drop_chooser_route(drive_idx), } self.request_redraw(); } @@ -3058,6 +3128,23 @@ impl App { } return true; } + // Drop chooser: a digit picks the Nth listed drive (the button + // labels carry the same numbers). + if let Some(Panel::DropChooser(state)) = &self.ui.panel { + let index = match code { + KeyCode::Digit1 => Some(0), + KeyCode::Digit2 => Some(1), + KeyCode::Digit3 => Some(2), + KeyCode::Digit4 => Some(3), + _ => None, + }; + if let Some(index) = index { + if let Some(drive) = state.drives.get(index).map(|entry| entry.drive) { + self.drop_chooser_route(drive); + } + return true; + } + } // Route keys to a focused plugin-option text field, if any. if self.launcher_handle_edit_key(code, text) { return true; @@ -5020,9 +5107,11 @@ impl App { Panel::FrameAnalyzer(panel) => Some(ui::PanelViewData::FrameAnalyzer(Box::new( self.build_frame_analyzer_view(panel), ))), - // The console and configuration panels render from their own state. + // The console, configuration, and drop-chooser panels render + // from their own state. Panel::Console(_) => None, Panel::Launcher(_) => None, + Panel::DropChooser(_) => None, } } @@ -5813,23 +5902,32 @@ impl App { // pacer would fast-forward to catch up and corrupt pacing for the // freshly inserted disk. insert_disk_image -> bus floppy // insert_disk_image already asserts the disk-change/eject signal. - if let Some(paths) = picked.filter(|paths| !paths.is_empty()) { - let count = paths.len(); - let path = paths[0].clone(); - self.disk_playlists[drive_idx] = paths; - self.disk_playlist_index[drive_idx] = 0; - let name = display_file_name(&path); - if self.insert_disk_image(drive_idx, path, self.disk_write_protected[drive_idx]) { - if count > 1 { - self.show_osd(format!("DF{drive_idx}: {name} (1/{count})")); - } else { - self.show_osd(format!("DF{drive_idx}: {name}")); - } + if let Some(paths) = picked { + self.insert_disk_playlist(drive_idx, paths); + } + self.finish_host_io_pause(); + } + + /// Replace a drive's swap playlist with `paths` and insert the first + /// image, with the standard OSD. Shared by the load dialog and window + /// drops. + fn insert_disk_playlist(&mut self, drive_idx: usize, paths: Vec) { + let Some(path) = paths.first().cloned() else { + return; + }; + let count = paths.len(); + self.disk_playlists[drive_idx] = paths; + self.disk_playlist_index[drive_idx] = 0; + let name = display_file_name(&path); + if self.insert_disk_image(drive_idx, path, self.disk_write_protected[drive_idx]) { + if count > 1 { + self.show_osd(format!("DF{drive_idx}: {name} (1/{count})")); } else { - self.show_osd(format!("DF{drive_idx}: load failed (see log)")); + self.show_osd(format!("DF{drive_idx}: {name}")); } + } else { + self.show_osd(format!("DF{drive_idx}: load failed (see log)")); } - self.finish_host_io_pause(); } /// Advance the disk-swap playlist of the first drive that has more @@ -5891,22 +5989,28 @@ impl App { // Re-baseline pacing after the modal dialog, as for floppies. if let Some(path) = picked { - match crate::cdrom::CdImage::load(&path) { - Ok(image) => { - info!("cd image: {} ({})", path.display(), image.describe()); - self.emu.bus_mut().cd_insert_disc(image); - self.show_osd(format!("CD: {}", display_file_name(&path))); - self.request_redraw(); - } - Err(e) => { - warn!("cd image load failed ({}): {e:#}", path.display()); - self.show_osd("CD: load failed (see log)"); - } - } + self.insert_cd_image_from_path(&path); } self.finish_host_io_pause(); } + /// Mount a CD image with the media-change notification, ejecting any + /// current disc first. Shared by the load dialog and window drops. + fn insert_cd_image_from_path(&mut self, path: &std::path::Path) { + match crate::cdrom::CdImage::load(path) { + Ok(image) => { + info!("cd image: {} ({})", path.display(), image.describe()); + self.emu.bus_mut().cd_insert_disc(image); + self.show_osd(format!("CD: {}", display_file_name(path))); + self.request_redraw(); + } + Err(e) => { + warn!("cd image load failed ({}): {e:#}", path.display()); + self.show_osd("CD: load failed (see log)"); + } + } + } + fn eject_cd(&mut self) { if !self.emu.bus().cd_disc_inserted() { self.show_osd("CD: no disc"); @@ -5917,6 +6021,108 @@ impl App { self.request_redraw(); } + /// Route files dropped on the window: floppy images to a drive + /// (directly, or via the chooser panel when several drives could take + /// them), a cue sheet to the CD drive, and everything else to an + /// explanatory notice. winit reports drops with no cursor position, so + /// the target drive can only be picked after the fact. + fn handle_dropped_files(&mut self, files: Vec) { + // The configuration screen runs on a placeholder machine: an insert + // would target hardware the launcher is about to rebuild, and the + // chooser would replace the launcher panel and its unsaved state. + if matches!(self.ui.panel, Some(Panel::Launcher(_))) { + self.show_osd("Close the machine screen to drop disks"); + return; + } + let mut floppies: Vec = Vec::new(); + let mut cue: Option = None; + let mut notice: Option<&'static str> = None; + for path in files { + match classify_dropped_media(&path) { + DroppedMediaKind::Floppy => floppies.push(path), + // One disc tray; the first cue sheet wins. + DroppedMediaKind::CdCue => cue = cue.or(Some(path)), + DroppedMediaKind::HardDisk => { + notice = Some("Hard disks are configured in the machine screen"); + } + DroppedMediaKind::Rom => { + notice = Some("Kickstart ROMs are configured in the machine screen"); + } + } + } + let mut handled = false; + if let Some(path) = cue { + if self.emu.bus().cd_drive_present() { + self.insert_cd_image_from_path(&path); + } else { + self.show_osd("No CD drive on this machine"); + } + handled = true; + } + if !floppies.is_empty() { + let connected: Vec = (0..4) + .filter(|&idx| self.emu.bus().floppy.drive_connected(idx)) + .collect(); + match connected.len() { + 0 => self.show_osd("No floppy drive connected"), + 1 => self.insert_disk_playlist(connected[0], floppies), + _ => { + // The chooser takes the panel slot; an open menu or an + // informational panel (About, Shortcuts...) yields to it. + self.ui.menu_open = false; + if self.ui.panel.is_some() { + self.close_panel(); + } + self.open_drop_chooser(floppies, connected); + } + } + handled = true; + } + if !handled { + if let Some(text) = notice { + self.show_osd(text); + } + } + } + + /// Open the modal drive chooser for dropped floppy images. Drive labels + /// are snapshotted now; the panel is modal, so they cannot go stale + /// under it. + fn open_drop_chooser(&mut self, disks: Vec, connected: Vec) { + let floppy = &self.emu.bus().floppy; + let drives = connected + .into_iter() + .map(|drive| { + let label = match floppy.inserted_disk_name(drive) { + Some(name) => format!("DF{drive}: {name}"), + None => format!("DF{drive} (empty)"), + }; + ui::DropDriveEntry { drive, label } + }) + .collect(); + let disk_label = display_file_name(&disks[0]); + self.ui.panel = Some(Panel::DropChooser(ui::DropChooserState { + disks, + disk_label, + drives, + })); + self.request_redraw(); + } + + /// Chooser click or digit key: insert the pending dropped disks into + /// the picked drive and close the panel. + fn drop_chooser_route(&mut self, drive_idx: usize) { + let state = match self.ui.panel.take() { + Some(Panel::DropChooser(state)) => state, + other => { + self.ui.panel = other; + return; + } + }; + self.insert_disk_playlist(drive_idx, state.disks); + self.request_redraw(); + } + fn insert_disk_image( &mut self, drive_idx: usize, diff --git a/src/video/window/tests.rs b/src/video/window/tests.rs index 8b674160..5461a5cf 100644 --- a/src/video/window/tests.rs +++ b/src/video/window/tests.rs @@ -3414,3 +3414,174 @@ fn debugger_poke_writes_memory_and_registers() { app.debugger_poke(); assert_eq!(app.emu.machine.d(3), 0x1234_5678); } + +// --- dropped disk images ------------------------------------------------- + +/// A blank standard ADF written to a unique temp path (floppy inserts read +/// from the filesystem). Callers remove it when done. +fn temp_adf(name: &str) -> PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let counter = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("copperline-drop-test-{nanos}-{counter}-{name}")); + std::fs::write(&path, vec![0u8; crate::floppy::ADF_SIZE]).unwrap(); + path +} + +#[test] +fn dropped_media_classifies_by_extension() { + use super::{classify_dropped_media, DroppedMediaKind}; + let kind = |name: &str| classify_dropped_media(std::path::Path::new(name)); + assert_eq!(kind("game.adf"), DroppedMediaKind::Floppy); + assert_eq!(kind("game.ADZ"), DroppedMediaKind::Floppy); + assert_eq!(kind("game.dms"), DroppedMediaKind::Floppy); + assert_eq!(kind("dump.scp"), DroppedMediaKind::Floppy); + assert_eq!(kind("game.adf.gz"), DroppedMediaKind::Floppy); + assert_eq!(kind("game.zip"), DroppedMediaKind::Floppy); + assert_eq!(kind("mystery"), DroppedMediaKind::Floppy); + assert_eq!(kind("game.CUE"), DroppedMediaKind::CdCue); + assert_eq!(kind("disk.hdf"), DroppedMediaKind::HardDisk); + assert_eq!(kind("disk.img"), DroppedMediaKind::HardDisk); + assert_eq!(kind("kick31.rom"), DroppedMediaKind::Rom); +} + +#[test] +fn dropped_floppy_with_single_drive_inserts_directly() { + let mut app = test_app(); + let adf = temp_adf("single.adf"); + app.handle_dropped_files(vec![adf.clone()]); + assert!(app.emu.bus().floppy.disk_inserted(0)); + assert_eq!(app.disk_playlists[0], vec![adf.clone()]); + assert!(app.ui.panel.is_none()); + assert!(app.osd.as_ref().unwrap().text.starts_with("DF0:")); + std::fs::remove_file(&adf).unwrap(); +} + +#[test] +fn dropped_floppies_with_multiple_drives_open_chooser() { + let mut app = test_app(); + app.emu + .bus_mut() + .floppy + .set_connected_drives([true, true, false, false]); + let disks = vec![PathBuf::from("disk1.adf"), PathBuf::from("disk2.adf")]; + app.handle_dropped_files(disks.clone()); + // Nothing inserted yet; the chooser lists exactly the connected drives. + assert!(!app.emu.bus().floppy.disk_inserted(0)); + match &app.ui.panel { + Some(Panel::DropChooser(state)) => { + assert_eq!(state.disks, disks); + assert_eq!(state.disk_label, "disk1.adf"); + let drives: Vec = state.drives.iter().map(|e| e.drive).collect(); + assert_eq!(drives, vec![0, 1]); + assert_eq!(state.drives[0].label, "DF0 (empty)"); + } + _ => panic!("drop chooser should be open"), + } +} + +#[test] +fn drop_chooser_click_routes_playlist_to_drive() { + let mut app = test_app(); + app.emu + .bus_mut() + .floppy + .set_connected_drives([true, true, false, false]); + let disk1 = temp_adf("multi1.adf"); + let disk2 = temp_adf("multi2.adf"); + app.handle_dropped_files(vec![disk1.clone(), disk2.clone()]); + assert!(matches!(app.ui.panel, Some(Panel::DropChooser(_)))); + + app.activate_ui_control(UiControl::DropDrive(1)); + assert!(app.ui.panel.is_none()); + assert!(app.emu.bus().floppy.disk_inserted(1)); + assert!(!app.emu.bus().floppy.disk_inserted(0)); + assert_eq!(app.disk_playlists[1], vec![disk1.clone(), disk2.clone()]); + assert_eq!(app.disk_playlist_index[1], 0); + assert!(app.osd.as_ref().unwrap().text.contains("(1/2)")); + std::fs::remove_file(&disk1).unwrap(); + std::fs::remove_file(&disk2).unwrap(); +} + +#[test] +fn drop_chooser_escape_cancels_without_insert() { + let mut app = test_app(); + app.emu + .bus_mut() + .floppy + .set_connected_drives([true, true, false, false]); + app.handle_dropped_files(vec![PathBuf::from("disk.adf")]); + assert!(matches!(app.ui.panel, Some(Panel::DropChooser(_)))); + + assert!(app.ui_handle_key(KeyCode::Escape, None)); + assert!(app.ui.panel.is_none()); + assert!(!app.emu.bus().floppy.disk_inserted(0)); + assert!(!app.emu.bus().floppy.disk_inserted(1)); +} + +#[test] +fn drop_chooser_digit_selects_listed_drive() { + let mut app = test_app(); + // DF0 and DF2 connected: digit 2 must pick the second LISTED drive + // (DF2), not literal DF1. + app.emu + .bus_mut() + .floppy + .set_connected_drives([true, false, true, false]); + let adf = temp_adf("digit.adf"); + app.handle_dropped_files(vec![adf.clone()]); + assert!(matches!(app.ui.panel, Some(Panel::DropChooser(_)))); + + assert!(app.ui_handle_key(KeyCode::Digit2, None)); + assert!(app.ui.panel.is_none()); + assert!(app.emu.bus().floppy.disk_inserted(2)); + std::fs::remove_file(&adf).unwrap(); +} + +#[test] +fn dropped_hard_disk_shows_notice_only() { + let mut app = test_app(); + app.handle_dropped_files(vec![PathBuf::from("system.hdf")]); + assert!(app.ui.panel.is_none()); + assert!(!app.emu.bus().floppy.disk_inserted(0)); + assert!(app.osd.as_ref().unwrap().text.contains("machine screen")); +} + +#[test] +fn dropped_cue_without_cd_drive_shows_notice() { + let mut app = test_app(); + app.handle_dropped_files(vec![PathBuf::from("game.cue")]); + assert!(app.ui.panel.is_none()); + assert_eq!( + app.osd.as_ref().map(|osd| osd.text.as_str()), + Some("No CD drive on this machine") + ); +} + +#[test] +fn drop_on_launcher_screen_is_refused() { + let mut app = test_app(); + app.open_launcher(); + app.handle_dropped_files(vec![PathBuf::from("disk.adf")]); + // The launcher (and its unsaved state) survives; nothing was inserted. + assert!(matches!(app.ui.panel, Some(Panel::Launcher(_)))); + assert!(!app.emu.bus().floppy.disk_inserted(0)); + assert!(app.osd.as_ref().unwrap().text.contains("machine screen")); +} + +#[test] +fn dropped_files_coalesce_across_events() { + let mut app = test_app(); + // One DroppedFile event per file lands in the pending list; the batch + // is then handled as a single action. + app.pending_dropped_files.push(PathBuf::from("a.adf")); + app.pending_dropped_files.push(PathBuf::from("b.adf")); + let files = std::mem::take(&mut app.pending_dropped_files); + app.handle_dropped_files(files); + // Single drive connected: both disks become DF0's playlist. + assert_eq!(app.disk_playlists[0].len(), 2); +}