diff --git a/src/video/ui.rs b/src/video/ui.rs index b08c50c..48bfc29 100644 --- a/src/video/ui.rs +++ b/src/video/ui.rs @@ -3919,7 +3919,12 @@ fn clip_path_tail(text: &str, avail_px: usize) -> String { /// rather than cutting into the name. Splits on both `/` and `\` so Windows and /// Unix paths work. If even the name alone is too wide, its tail is shown. fn clip_path_keep_name(text: &str, avail_px: usize) -> String { - let max_chars = avail_px / font::GLYPH_W; + clip_path_to_chars(text, avail_px / font::GLYPH_W) +} + +/// [`clip_path_keep_name`] in characters rather than pixels, shared with the +/// status line (see `window::shorten_status_paths`). +pub(super) fn clip_path_to_chars(text: &str, max_chars: usize) -> String { if text.chars().count() <= max_chars { return text.to_string(); } @@ -3942,7 +3947,7 @@ fn clip_path_keep_name(text: &str, avail_px: usize) -> String { prefixed } else { // The file name alone does not fit; fall back to a plain tail clip. - clip_path_tail(name, avail_px) + clip_path_tail(name, max_chars * font::GLYPH_W) } } diff --git a/src/video/window.rs b/src/video/window.rs index 2694dfe..2b612ed 100644 --- a/src/video/window.rs +++ b/src/video/window.rs @@ -2610,12 +2610,49 @@ fn classify_dropped_media(path: &std::path::Path) -> DroppedMediaKind { } } +/// Shorten any filesystem path in a status message so its file name stays +/// visible: a long path keeps its final component behind a "..." prefix instead +/// of running past the panel. Windows and Unix paths both work. +/// +/// `anyhow`'s alternate Display joins the error chain with `": "`, and a path +/// sits at the end of its segment (`reading ROM `), so each segment is +/// clipped as one span. That keeps paths containing spaces intact instead of +/// splitting them into several fragments. +fn shorten_status_paths(msg: &str) -> String { + // Enough for the file name plus a directory or two, leaving room for the + // cause after it; the status line holds roughly eighty characters. + const MAX_PATH_CHARS: usize = 28; + msg.split(": ") + .map(|segment| { + let Some(sep) = segment.find(['/', '\\']) else { + return segment.to_string(); + }; + // The path runs from the token holding the first separator (back up + // to the preceding space) to the end of the segment. + let start = segment[..sep].rfind(' ').map_or(0, |i| i + 1); + let (prose, path) = segment.split_at(start); + format!("{prose}{}", ui::clip_path_to_chars(path, MAX_PATH_CHARS)) + }) + .collect::>() + .join(": ") +} + /// A one-line, length-bounded form of an error for the configuration panel's -/// status line (the full chain still goes to the log). +/// status line. `{:#}` walks the whole chain, so the cause is kept: showing +/// only the outermost context turned "reading ROM " into what looked like +/// a progress message when the ROM was simply not there. Paths are shortened to +/// their file name and the first letter capitalised so it reads as a sentence +/// instead of trailing off past the edge of the panel. fn short_status_error(err: &anyhow::Error) -> String { - let msg = err.to_string(); + let msg = format!("{err:#}"); let first_line = msg.lines().next().unwrap_or("").trim(); - first_line.chars().take(96).collect() + let shortened = shorten_status_paths(first_line); + let mut chars = shortened.chars(); + let sentence = match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + }; + sentence.chars().take(96).collect() } fn set_mouse_button(emu: &mut Emulator, port: u8, button: MouseButtonKind, pressed: bool) { @@ -4529,12 +4566,16 @@ impl App { let mut cfg = match self.launcher_state().map(|s| s.setup.build_config()) { Some(Ok(cfg)) => cfg, Some(Err(e)) => { + // The status line is one shortened sentence; the log keeps the + // whole chain (which names the underlying cause). + warn!("run failed: {e:#}"); self.set_launcher_status(StatusMessage::err(short_status_error(&e))); return; } None => return, }; if let Err(e) = crate::config::resolve_bundled_rom(&mut cfg) { + warn!("run failed: {e:#}"); self.set_launcher_status(StatusMessage::err(short_status_error(&e))); return; } @@ -4564,6 +4605,7 @@ impl App { let emu = match crate::emulator::build_machine(&cfg, audio, true, false) { Ok(emu) => emu, Err(e) => { + warn!("run failed: {e:#}"); self.set_launcher_status(StatusMessage::err(short_status_error(&e))); return; } diff --git a/src/video/window/tests.rs b/src/video/window/tests.rs index b66ad00..6bc739e 100644 --- a/src/video/window/tests.rs +++ b/src/video/window/tests.rs @@ -14,18 +14,18 @@ use super::{ parse_amiga_key, pause_button_rect, power_button_rect, present_height, presentation_h_shift_for, presentation_source_y_offset, raw_device_qualifier_family_held, raw_device_qualifier_rawkey, rawkey_is_held, rawkey_transition_is_duplicate, - reboot_button_rect, repeated_main_key_should_drop, rgba, shot_button_rect, - should_render_emulated_frame, standard_window_top_row, status_with_latched_fdd_track, - take_integral_mouse_delta, texture_height, texture_width, tv_aperture_source_row, - tv_source_h_bounds, volume_percent_from_pos, volume_slider_track_rect, BarControl, DriveBar, - JoystickInputMode, KeyboardJoystickHeld, KeyboardJoystickKey, MediaBar, StatusBarView, - ToolPanelKind, AMIGA_RAWKEY_LEFT_ALT, AMIGA_RAWKEY_LEFT_SHIFT, AMIGA_RAWKEY_RIGHT_ALT, - AMIGA_RAWKEY_RIGHT_SHIFT, BUTTON_GLYPH, BUTTON_GLYPH_DISABLED, CD_BODY, CD_LED_OFF, CD_LED_ON, - DISK_BODY, DISK_BODY_SHADOW, DISK_LABEL, FDD_LED_OFF, FDD_LED_ON, HDD_LED_OFF, HDD_LED_ON, - POWER_GLYPH_OFF, POWER_GLYPH_ON, POWER_LED_OFF, POWER_LED_ON, STANDARD_PAL_VISIBLE_LINES, - STANDARD_PAL_VISIBLE_START_VPOS, STATUS_BG, TRACK_SEGMENT_OFF, TRACK_SEGMENT_ON, - TV_PAL_LIVE_PAD_X, TV_PAL_PRESENT_HEIGHT, TV_PAL_PRESENT_SOURCE_X, TV_PAL_PRESENT_SOURCE_Y, - TV_PAL_PRESENT_WIDTH, VOLUME_FILL, VOLUME_GLYPH_X, + reboot_button_rect, repeated_main_key_should_drop, rgba, short_status_error, + shorten_status_paths, shot_button_rect, should_render_emulated_frame, standard_window_top_row, + status_with_latched_fdd_track, take_integral_mouse_delta, texture_height, texture_width, + tv_aperture_source_row, tv_source_h_bounds, volume_percent_from_pos, volume_slider_track_rect, + BarControl, DriveBar, JoystickInputMode, KeyboardJoystickHeld, KeyboardJoystickKey, MediaBar, + StatusBarView, ToolPanelKind, AMIGA_RAWKEY_LEFT_ALT, AMIGA_RAWKEY_LEFT_SHIFT, + AMIGA_RAWKEY_RIGHT_ALT, AMIGA_RAWKEY_RIGHT_SHIFT, BUTTON_GLYPH, BUTTON_GLYPH_DISABLED, CD_BODY, + CD_LED_OFF, CD_LED_ON, DISK_BODY, DISK_BODY_SHADOW, DISK_LABEL, FDD_LED_OFF, FDD_LED_ON, + HDD_LED_OFF, HDD_LED_ON, POWER_GLYPH_OFF, POWER_GLYPH_ON, POWER_LED_OFF, POWER_LED_ON, + STANDARD_PAL_VISIBLE_LINES, STANDARD_PAL_VISIBLE_START_VPOS, STATUS_BG, TRACK_SEGMENT_OFF, + TRACK_SEGMENT_ON, TV_PAL_LIVE_PAD_X, TV_PAL_PRESENT_HEIGHT, TV_PAL_PRESENT_SOURCE_X, + TV_PAL_PRESENT_SOURCE_Y, TV_PAL_PRESENT_WIDTH, VOLUME_FILL, VOLUME_GLYPH_X, }; use crate::audio::{AudioSink, NullSink}; use crate::bus::{FrontPanelStatus, RenderRegisterSnapshot}; @@ -4087,3 +4087,57 @@ mod control_drain { ); } } + +#[test] +fn a_missing_rom_reads_as_a_failure_with_a_shortened_path() { + // A config naming a ROM that is not there must say so, not read like a + // progress message, and must not run the whole path past the panel. A + // synthetic NotFound cause keeps this deterministic and off the filesystem. + let cause = std::io::Error::new(std::io::ErrorKind::NotFound, "no such file"); + let err = anyhow::Error::new(cause) + .context("reading ROM /Users/me/Desktop/Amiga/kickstarts/roms/kick31.rom".to_string()); + + // The cause is what says it failed, so it must survive; the path is + // shortened to keep the whole line inside the panel. + let status = short_status_error(&err); + assert!( + status.starts_with("Reading ROM .../roms/kick31.rom:"), + "{status}" + ); + assert!(status.contains("no such file"), "{status}"); + assert!(!status.contains("/Users/me/Desktop"), "{status}"); + assert!(status.chars().count() <= 80, "{status}"); +} + +#[test] +fn status_paths_keep_the_file_name() { + // Short paths are left alone. + let short = "unable to read ROM roms/kick.rom"; + assert_eq!(shorten_status_paths(short), short); + + // A long Unix path collapses to its file name. + let unix = "unable to read ROM /Users/me/Desktop/Amiga/roms/kickstart31.rom"; + let out = shorten_status_paths(unix); + assert!(out.ends_with("kickstart31.rom"), "{out}"); + assert!(out.contains("..."), "{out}"); + + // A long Windows path keeps its separator and file name. + let win = r"unable to read ROM C:\Users\me\Documents\Amiga\roms\kickstart31.rom"; + let out = shorten_status_paths(win); + assert!(out.ends_with("kickstart31.rom"), "{out}"); + assert!(out.contains('\\'), "{out}"); + + // A path containing spaces is clipped as one span, not split apart into + // several "..." fragments, and the cause after it survives. + let spaced = "reading ROM /Users/me/My Amiga Roms/kickstart31.rom: no such file"; + let out = shorten_status_paths(spaced); + assert!(out.contains("kickstart31.rom"), "{out}"); + assert!(out.ends_with(": no such file"), "{out}"); + assert_eq!(out.matches("...").count(), 1, "{out}"); + + // The cause is kept behind the "reading ROM" context (Display's ": " chain). + let with_cause = + "unable to read extended ROM /Users/me/Desktop/Amiga/roms/cd32ext.rom: No such file"; + let out = shorten_status_paths(with_cause); + assert!(out.contains("cd32ext.rom: No such file"), "{out}"); +}