diff --git a/juliaupgui/src/app.rs b/juliaupgui/src/app.rs index 9eefcdf6..090c676a 100644 --- a/juliaupgui/src/app.rs +++ b/juliaupgui/src/app.rs @@ -39,10 +39,19 @@ struct InstalledRow { name: String, version: String, is_default: bool, - update: Option, + update: Option, pr_number: Option, } +/// A pending channel update, split into what fits in the UI and what does not. +#[derive(Clone)] +struct UpdateInfo { + /// Short text for the tile badge and the list column, e.g. `1.10.12`. + label: String, + /// Full text for the tooltip, e.g. `Update to 1.10.12+0.aarch64.apple.darwin14`. + detail: String, +} + #[derive(Clone)] struct AvailableRow { channel: String, @@ -224,6 +233,7 @@ pub struct App { impl App { pub fn new(cc: &eframe::CreationContext<'_>, paths: GlobalPaths) -> Self { let paths = Arc::new(paths); + install_font_fallbacks(&cc.egui_ctx); let theme_mode = load_theme_pref(&paths); apply_theme(&cc.egui_ctx, theme_mode); let (op_tx, op_rx) = mpsc::sync_channel::<(Op, Arc)>(8); @@ -989,7 +999,8 @@ fn tab_installed_tiles(app: &mut App, ui: &mut egui::Ui, state: &AppState) { .color(secondary_text(ui.visuals().dark_mode)), ) .truncate(), - ); + ) + .on_hover_text(&row.version); if row.is_default || row.update.is_some() { ui.add_space(4.0); @@ -1003,112 +1014,129 @@ fn tab_installed_tiles(app: &mut App, ui: &mut egui::Ui, state: &AppState) { ); } if let Some(upd) = &row.update { - ui.label( - RichText::new(format!("Update: {upd}")) - .size(12.0) - .color(warning_color(ui.visuals().dark_mode)), - ); + ui.add( + egui::Label::new( + RichText::new(format!("Update: {}", upd.label)) + .size(12.0) + .color(warning_color(ui.visuals().dark_mode)), + ) + .truncate(), + ) + .on_hover_text(upd.detail.as_str()); } }); } - // ── push buttons to bottom ── - let used = ui.min_rect().height(); + // ── buttons, pinned to the bottom of the tile ── + // `min_rect` spans the full tile, not the content + // above it, so stack the rows up from the bottom + // rather than measuring. let btn_h = 24.0; - let btn_rows = if row.is_default { 1 } else { 2 }; - let buttons_h = btn_h * btn_rows as f32 + 4.0 * (btn_rows - 1) as f32; - let remaining = (TILE_H - used - buttons_h).max(0.0); - ui.add_space(remaining); - - // ── Launch row ── - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let launch_w = (TILE_W - 4.0) / 2.0; - if accessible_button_name( - ui.add_sized( - [launch_w, btn_h], - egui::Button::new( - RichText::new("Launch") - .size(12.0) - .color(success_color(ui.visuals().dark_mode)), - ), - ), - format!("Launch Julia channel {}", row.name), - ) - .on_hover_text(format!("Start julia +{}", row.name)) - .clicked() - { - do_launch = Some(row.name.clone()); - } - if accessible_button_name( - ui.add_sized( - [launch_w, btn_h], - egui::Button::new(RichText::new("Custom...").size(12.0)), - ), - format!( - "Launch Julia channel {} with custom options", - row.name - ), - ) - .on_hover_text("Launch with custom project & args") - .clicked() - { - app.custom_launch_channel = Some(row.name.clone()); + ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| { + // ── secondary actions row ── + // The default channel cannot be re-defaulted or + // removed, but it can still be updated. + let has_update = row.update.is_some(); + let actions = + usize::from(!row.is_default) * 2 + usize::from(has_update); + if actions > 0 { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let action_w = + (TILE_W - 4.0 * (actions - 1) as f32) / actions as f32; + if !row.is_default + && accessible_button_name( + ui.add_sized( + [action_w, btn_h], + egui::Button::new( + RichText::new("Default").size(12.0), + ), + ), + format!( + "Set Julia channel {} as default", + row.name + ), + ) + .on_hover_text( + "Use this channel when no version is specified", + ) + .clicked() + { + set_def = Some(row.name.clone()); + } + if has_update + && accessible_button_name( + ui.add_sized( + [action_w, btn_h], + egui::Button::new( + RichText::new("Update").size(12.0), + ), + ), + format!("Update Julia channel {}", row.name), + ) + .on_hover_text("Update to latest") + .clicked() + { + do_update = Some(row.name.clone()); + } + if !row.is_default + && accessible_button_name( + ui.add_sized( + [action_w, btn_h], + egui::Button::new( + RichText::new("Remove").size(12.0), + ), + ), + format!("Remove Julia channel {}", row.name), + ) + .on_hover_text("Remove this channel") + .clicked() + { + do_remove = Some(row.name.clone()); + } + }); + ui.add_space(4.0); } - }); - // ── secondary actions row ── - if !row.is_default { - ui.add_space(4.0); + // ── Launch row ── ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 4.0; - let has_update = row.update.is_some(); - let action_w = if has_update { - (TILE_W - 8.0) / 3.0 - } else { - (TILE_W - 4.0) / 2.0 - }; + let launch_w = (TILE_W - 4.0) / 2.0; if accessible_button_name( ui.add_sized( - [action_w, btn_h], - egui::Button::new(RichText::new("Default").size(12.0)), + [launch_w, btn_h], + egui::Button::new( + RichText::new("Launch") + .size(12.0) + .color(success_color(ui.visuals().dark_mode)), + ), ), - format!("Set Julia channel {} as default", row.name), + format!("Launch Julia channel {}", row.name), ) - .on_hover_text("Use this channel when no version is specified") + .on_hover_text(format!("Start julia +{}", row.name)) .clicked() { - set_def = Some(row.name.clone()); - } - if has_update - && accessible_button_name( - ui.add_sized( - [action_w, btn_h], - egui::Button::new( - RichText::new("Update").size(12.0), - ), - ), - format!("Update Julia channel {}", row.name), - ) - .on_hover_text("Update to latest") - .clicked() - { - do_update = Some(row.name.clone()); + do_launch = Some(row.name.clone()); } if accessible_button_name( ui.add_sized( - [action_w, btn_h], - egui::Button::new(RichText::new("Remove").size(12.0)), + [launch_w, btn_h], + egui::Button::new( + RichText::new("Custom...").size(12.0), + ), + ), + format!( + "Launch Julia channel {} with custom options", + row.name ), - format!("Remove Julia channel {}", row.name), ) - .on_hover_text("Remove this channel") + .on_hover_text("Launch with custom project & args") .clicked() { - do_remove = Some(row.name.clone()); + app.custom_launch_channel = Some(row.name.clone()); } }); - } + }); } } else { // "Add another channel" tile @@ -1249,19 +1277,27 @@ fn tab_installed_list(app: &mut App, ui: &mut egui::Ui, state: &AppState) { }); }); cells.col(|ui| { - ui.label( - RichText::new(&row.version) - .size(13.0) - .color(secondary_text(ui.visuals().dark_mode)), - ); + ui.add( + egui::Label::new( + RichText::new(&row.version) + .size(13.0) + .color(secondary_text(ui.visuals().dark_mode)), + ) + .truncate(), + ) + .on_hover_text(&row.version); }); cells.col(|ui| { if let Some(upd) = &row.update { - ui.label( - RichText::new(upd) - .size(12.0) - .color(warning_color(ui.visuals().dark_mode)), - ); + ui.add( + egui::Label::new( + RichText::new(&upd.label) + .size(12.0) + .color(warning_color(ui.visuals().dark_mode)), + ) + .truncate(), + ) + .on_hover_text(upd.detail.as_str()); } else { ui.label( RichText::new("Current") @@ -3122,18 +3158,24 @@ fn update_info( ch: &JuliaupConfigChannel, config: &juliaup::config_file::JuliaupReadonlyConfigFile, versiondb: &JuliaupVersionDB, -) -> Option { +) -> Option { match ch { JuliaupConfigChannel::DirectDownloadChannel { local_etag, server_etag, .. - } => (local_etag != server_etag).then(|| "Update available".to_string()), + } => (local_etag != server_etag).then(|| UpdateInfo { + label: "available".to_string(), + detail: "A newer build of this channel is available".to_string(), + }), JuliaupConfigChannel::SystemChannel { version } => versiondb .available_channels .get(name) .filter(|c| &c.version != version) - .map(|c| format!("→ {}", c.version)), + .map(|c| UpdateInfo { + label: short_target_version(version, &c.version), + detail: format!("Update to {}", c.version), + }), JuliaupConfigChannel::LinkedChannel { .. } => None, JuliaupConfigChannel::AliasChannel { target, .. } => config .data @@ -3143,6 +3185,18 @@ fn update_info( } } +/// Drops the `+.` tag when it matches the installed version: +/// the version line above already shows it, and the badge has a 168pt tile to +/// fit in. A differing platform is worth seeing, so keep that. +fn short_target_version(installed: &str, target: &str) -> String { + match (installed.split_once('+'), target.split_once('+')) { + (Some((_, installed_tag)), Some((core, target_tag))) if installed_tag == target_tag => { + core.to_string() + } + _ => target.to_string(), + } +} + // ── splash animation ────────────────────────────────────────────────────────── // Total animation: hold (0.3s) -> fade out (0.4s) @@ -3214,6 +3268,16 @@ fn paint_julia_dots(p: &egui::Painter, rect: egui::Rect) { // ── theme ───────────────────────────────────────────────────────────────────── +/// Ubuntu-Light, egui's proportional font, has no arrows, so `→` renders as a +/// tofu box. Hack ships with egui and covers them; append it as a fallback. +fn install_font_fallbacks(ctx: &egui::Context) { + let mut fonts = egui::FontDefinitions::default(); + if let Some(family) = fonts.families.get_mut(&egui::FontFamily::Proportional) { + family.push("Hack".to_owned()); + } + ctx.set_fonts(fonts); +} + fn apply_theme(ctx: &egui::Context, mode: ThemeMode) { for theme in [egui::Theme::Dark, egui::Theme::Light] { let dark = theme == egui::Theme::Dark; @@ -3370,6 +3434,62 @@ pub fn run(paths: GlobalPaths) -> anyhow::Result<()> { mod tests { use super::*; + // ── update badge text ───────────────────────────────────────────────── + + #[test] + fn short_target_version_drops_the_shared_build_tag() { + assert_eq!( + short_target_version( + "1.10.11+0.aarch64.apple.darwin14", + "1.10.12+0.aarch64.apple.darwin14" + ), + "1.10.12" + ); + } + + #[test] + fn short_target_version_keeps_a_differing_build_tag() { + assert_eq!( + short_target_version( + "1.10.11+0.x64.apple.darwin14", + "1.10.12+0.aarch64.apple.darwin14" + ), + "1.10.12+0.aarch64.apple.darwin14" + ); + } + + #[test] + fn short_target_version_passes_through_untagged_versions() { + assert_eq!( + short_target_version("1.14.0-DEV.1", "1.14.0-DEV.2"), + "1.14.0-DEV.2" + ); + } + + // ── fonts ───────────────────────────────────────────────────────────── + + #[test] + fn proportional_font_renders_arrows_after_fallback() { + // egui hands back texture deltas that panic if simply dropped. + fn pass(ctx: &egui::Context) { + ctx.run_ui(Default::default(), |_| {}) + .textures_delta + .clear(); + } + + let arrow = egui::FontId::proportional(12.0); + let ctx = egui::Context::default(); + pass(&ctx); + assert!( + !ctx.fonts_mut(|f| f.has_glyph(&arrow, '\u{2192}')), + "egui's default proportional font now covers arrows; install_font_fallbacks can go" + ); + + install_font_fallbacks(&ctx); + pass(&ctx); + assert!(ctx.fonts_mut(|f| f.has_glyph(&arrow, '\u{2192}'))); + } + // ── clean_line ──────────────────────────────────────────────────────── #[test] diff --git a/src/command_status.rs b/src/command_status.rs index 8db7e805..31a3b8b5 100644 --- a/src/command_status.rs +++ b/src/command_status.rs @@ -9,8 +9,9 @@ use cli_table::format::Separator; use cli_table::ColorChoice; use cli_table::{ format::{Border, Justify}, - print_stdout, Table, WithTitle, + print_stdout, Table, TableStruct, WithTitle, }; +use console::Term; use itertools::Itertools; use numeric_sort::cmp; use regex::Regex; @@ -42,10 +43,28 @@ fn format_linked_command(command: &str, args: &Option>) -> String { format!("Linked to `{combined_command}`") } -fn format_version(channel_name: &str, channel: &JuliaupConfigChannel) -> String { +fn strip_build_tag(version: &str) -> &str { + version.split_once('+').map_or(version, |(core, _)| core) +} + +/// The update column only has to say what changes, and the build tag almost +/// never does. Keep it when it differs: a change of platform is worth seeing. +fn short_target_version(installed: &str, target: &str) -> String { + match (installed.split_once('+'), target.split_once('+')) { + (Some((_, installed_tag)), Some((core, target_tag))) if installed_tag == target_tag => { + core.to_string() + } + _ => target.to_string(), + } +} + +/// `compact` trades the details that repeat across rows (the build tag, the +/// pull request URL) for a table that fits a narrow terminal. +fn format_version(channel_name: &str, channel: &JuliaupConfigChannel, compact: bool) -> String { match channel { JuliaupConfigChannel::DirectDownloadChannel { version, .. } => { match Regex::new(r"^pr(\d+)").unwrap().captures(channel_name) { + Some(caps) if compact => format!("{version} (#{})", &caps[1]), Some(caps) => format!( "{version} https://github.com/JuliaLang/julia/pull/{}", &caps[1] @@ -53,6 +72,9 @@ fn format_version(channel_name: &str, channel: &JuliaupConfigChannel) -> String None => version.clone(), } } + JuliaupConfigChannel::SystemChannel { version } if compact => { + strip_build_tag(version).to_string() + } JuliaupConfigChannel::SystemChannel { version } => version.clone(), JuliaupConfigChannel::LinkedChannel { command, args } => { format_linked_command(command, args) @@ -77,11 +99,11 @@ fn get_update_info( local_etag, server_etag, .. - } => (local_etag != server_etag).then(|| "Update available".to_string()), + } => (local_etag != server_etag).then(|| "available".to_string()), JuliaupConfigChannel::SystemChannel { version } => { match versiondb_data.available_channels.get(channel_name) { Some(channel) if &channel.version != version => { - Some(format!("Update to {} available", channel.version)) + Some(short_target_version(version, &channel.version)) } _ => None, } @@ -94,11 +116,11 @@ fn get_update_info( local_etag, server_etag, .. - }) => (local_etag != server_etag).then(|| "Update available".to_string()), + }) => (local_etag != server_etag).then(|| "available".to_string()), Some(JuliaupConfigChannel::SystemChannel { version }) => { match versiondb_data.available_channels.get(target) { Some(channel) if channel.version != *version => { - Some(format!("Update to {} available", channel.version)) + Some(short_target_version(version, &channel.version)) } _ => None, } @@ -129,37 +151,62 @@ pub fn run_command_status(paths: &GlobalPaths) -> Result<()> { let versiondb_data = load_versions_db(paths).with_context(|| "`status` command failed to load versions db.")?; - let rows_in_table: Vec = config_file - .data - .installed_channels - .iter() - .sorted_by(|(channel_name_a, _), (channel_name_b, _)| cmp(channel_name_a, channel_name_b)) - .map(|(channel_name, channel)| ChannelRow { - default: match &config_file.data.default { - Some(ref default_value) if channel_name == default_value => "*", - _ => "", - }, - name: channel_name.to_string(), - version: format_version(channel_name, channel), - update: get_update_info(channel_name, channel, &config_file, &versiondb_data), - }) - .collect(); - - print_stdout( - rows_in_table - .with_title() - .color_choice(ColorChoice::Never) - .border(Border::builder().build()) - .separator( - Separator::builder() - .title(Some(HorizontalLine::new('1', '2', '3', '-'))) - .build(), - ), - )?; + let build_rows = |compact: bool| -> Vec { + config_file + .data + .installed_channels + .iter() + .sorted_by(|(channel_name_a, _), (channel_name_b, _)| { + cmp(channel_name_a, channel_name_b) + }) + .map(|(channel_name, channel)| ChannelRow { + default: match &config_file.data.default { + Some(ref default_value) if channel_name == default_value => "*", + _ => "", + }, + name: channel_name.to_string(), + version: format_version(channel_name, channel, compact), + update: get_update_info(channel_name, channel, &config_file, &versiondb_data), + }) + .collect() + }; + + // Only a real terminal has a width to respect; piped output keeps the URLs + // and build tags intact. + let compact = match Term::stdout().size_checked() { + Some((_, cols)) => rendered_width(styled_table(build_rows(false)))? > cols as usize, + None => false, + }; + + print_stdout(styled_table(build_rows(compact)))?; Ok(()) } +fn styled_table(rows: Vec) -> TableStruct { + rows.with_title() + .color_choice(ColorChoice::Never) + .border(Border::builder().build()) + .separator( + Separator::builder() + .title(Some(HorizontalLine::new('1', '2', '3', '-'))) + .build(), + ) +} + +/// Width of the widest rendered row. `TableDisplay` trims the ends of the whole +/// table, but the separator line it leaves untouched always spans the full +/// width, so the maximum is still exact. +fn rendered_width(table: TableStruct) -> Result { + Ok(table + .display()? + .to_string() + .lines() + .map(|line| line.chars().count()) + .max() + .unwrap_or(0)) +} + #[cfg(test)] mod tests { use super::*; @@ -178,7 +225,7 @@ mod tests { #[test] fn format_version_pr_channel_shows_pr_url() { assert_eq!( - format_version("pr59158", &direct_download_channel("1.14.0-DEV.123")), + format_version("pr59158", &direct_download_channel("1.14.0-DEV.123"), false), "1.14.0-DEV.123 https://github.com/JuliaLang/julia/pull/59158" ); } @@ -186,7 +233,11 @@ mod tests { #[test] fn format_version_pr_channel_with_platform_suffix_shows_pr_url() { assert_eq!( - format_version("pr59158~x64", &direct_download_channel("1.14.0-DEV.123")), + format_version( + "pr59158~x64", + &direct_download_channel("1.14.0-DEV.123"), + false + ), "1.14.0-DEV.123 https://github.com/JuliaLang/julia/pull/59158" ); } @@ -194,15 +245,96 @@ mod tests { #[test] fn format_version_nightly_channel_shows_only_version() { assert_eq!( - format_version("nightly", &direct_download_channel("1.14.0-DEV.456")), + format_version("nightly", &direct_download_channel("1.14.0-DEV.456"), false), "1.14.0-DEV.456" ); assert_eq!( - format_version("1.13-nightly", &direct_download_channel("1.13.0-DEV.789")), + format_version( + "1.13-nightly", + &direct_download_channel("1.13.0-DEV.789"), + false + ), "1.13.0-DEV.789" ); } + #[test] + fn format_version_compact_pr_channel_shows_pr_number() { + assert_eq!( + format_version("pr59158", &direct_download_channel("1.14.0-DEV.123"), true), + "1.14.0-DEV.123 (#59158)" + ); + } + + #[test] + fn format_version_compact_system_channel_drops_build_tag() { + assert_eq!( + format_version( + "1.11", + &JuliaupConfigChannel::SystemChannel { + version: "1.11.2+0.x64.apple.darwin14".to_string() + }, + true + ), + "1.11.2" + ); + } + + #[test] + fn short_target_version_drops_the_shared_build_tag() { + assert_eq!( + short_target_version( + "1.10.11+0.aarch64.apple.darwin14", + "1.10.12+0.aarch64.apple.darwin14" + ), + "1.10.12" + ); + } + + #[test] + fn short_target_version_keeps_a_differing_build_tag() { + assert_eq!( + short_target_version( + "1.10.11+0.x64.apple.darwin14", + "1.10.12+0.aarch64.apple.darwin14" + ), + "1.10.12+0.aarch64.apple.darwin14" + ); + } + + #[test] + fn compact_rows_fit_a_narrow_terminal() { + let rows = |compact: bool| { + vec![ + ChannelRow { + default: "*", + name: "release".to_string(), + version: format_version( + "release", + &JuliaupConfigChannel::SystemChannel { + version: "1.12.6+0.aarch64.apple.darwin14".to_string(), + }, + compact, + ), + update: "1.12.7".to_string(), + }, + ChannelRow { + default: "", + name: "pr62359".to_string(), + version: format_version( + "pr62359", + &direct_download_channel("1.14.0-DEV.2875"), + compact, + ), + update: String::new(), + }, + ] + }; + + assert!(rendered_width(styled_table(rows(false))).unwrap() > 80); + assert!(rendered_width(styled_table(rows(true))).unwrap() <= 80); + } + #[test] fn format_version_system_channel_unchanged() { assert_eq!( @@ -210,7 +342,8 @@ mod tests { "1.11", &JuliaupConfigChannel::SystemChannel { version: "1.11.2+0.x64.apple.darwin14".to_string() - } + }, + false ), "1.11.2+0.x64.apple.darwin14" );