diff --git a/src/app/mod.rs b/src/app/mod.rs index e3858131b..14a080e0c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -104,6 +104,10 @@ pub(super) struct H7CAD { /// Tag of the latest available release (without the leading "v"), /// e.g. `"0.3.0"`. `None` when up-to-date or check hasn't returned. update_notice_version: Option, + /// Release-notes body for the version above (GitHub release "body" + /// markdown, as returned by the API). May be empty when the release + /// shipped without notes. + update_notice_body: Option, /// In-memory clipboard: cloned entities waiting to be pasted. clipboard: Vec, /// Centroid of the clipboard entities (XZ plane, Y-up). @@ -542,7 +546,7 @@ pub enum Message { Noop, /// GitHub releases API returned a result. `Some(version)` means a /// newer release exists; we open the update-notice window. - UpdateCheckResult(Option), + UpdateCheckResult(Option), /// User dismissed the update-notice window. UpdateNoticeClose, /// User clicked the "Open release page" button — opens the GitHub @@ -739,6 +743,7 @@ impl H7CAD { about_window: None, update_notice_window: None, update_notice_version: None, + update_notice_body: None, clipboard: Vec::new(), clipboard_centroid: glam::Vec3::ZERO, layout_context_menu: None, diff --git a/src/app/update.rs b/src/app/update.rs index 7a3f4380f..52c075770 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -3688,16 +3688,20 @@ impl H7CAD { } } Message::UpdateCheckResult(latest) => { - let Some(version) = latest else { + let Some(info) = latest else { return Task::none(); }; - self.update_notice_version = Some(version); + self.update_notice_version = Some(info.version); + self.update_notice_body = Some(info.body); if let Some(id) = self.update_notice_window { return window::gain_focus(id); } let (id, task) = window::open(window::Settings { - size: iced::Size::new(480.0, 240.0), - resizable: false, + // Sized for the new release-notes panel — wide enough + // for typical GitHub release headlines, tall enough for + // a meaningful scroll preview without dwarfing the app. + size: iced::Size::new(560.0, 460.0), + resizable: true, ..Default::default() }); self.update_notice_window = Some(id); diff --git a/src/app/view.rs b/src/app/view.rs index 2454b6247..bd9e5bd95 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -195,7 +195,8 @@ impl H7CAD { .update_notice_version .as_deref() .unwrap_or("?"); - return crate::ui::update_notice::view_window(latest); + let body = self.update_notice_body.as_deref().unwrap_or(""); + return crate::ui::update_notice::view_window(latest, body); } if Some(window_id) == self.unsaved_dialog_window { let tab_name = match &self.pending_close { diff --git a/src/ui/update_notice.rs b/src/ui/update_notice.rs index 68a5a6f0e..5fa8c9c7e 100644 --- a/src/ui/update_notice.rs +++ b/src/ui/update_notice.rs @@ -1,5 +1,5 @@ use crate::app::Message; -use iced::widget::{button, column, container, row, text, Space}; +use iced::widget::{button, column, container, row, scrollable, text, Space}; use iced::{Background, Border, Color, Element, Fill, Theme}; const BG: Color = Color { @@ -39,18 +39,104 @@ const WHITE: Color = Color { a: 1.0, }; -fn info_row<'a>(label: &'static str, value: String) -> Element<'a, Message> { - row![ - text(label).size(11).color(DIM).width(100), - text(value).size(11).color(WHITE), - ] - .spacing(8) - .align_y(iced::Center) - .padding([3, 0]) +/// Renders one of the two "Installed" / "Latest" cards. The `highlight` +/// flag tints the border + label with the accent colour, making the new +/// version the visual anchor of the row. +fn version_card<'a>(label: &'static str, value: String, highlight: bool) -> Element<'a, Message> { + let label_color = if highlight { ACCENT } else { DIM }; + let value_color = WHITE; + let border_color = if highlight { ACCENT } else { BORDER }; + let bg = if highlight { + Color { r: 0.10, g: 0.16, b: 0.22, a: 1.0 } // accent-tinted dark + } else { + PANEL + }; + container( + column![ + text(label).size(10).color(label_color), + text(value).size(20).color(value_color), + ] + .spacing(4) + .align_x(iced::Center), + ) + .width(Fill) + .padding(iced::Padding { + top: 14.0, + right: 12.0, + bottom: 14.0, + left: 12.0, + }) + .align_x(iced::Center) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(bg)), + border: Border { + color: border_color, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + }) .into() } -pub fn view_window<'a>(latest: &'a str) -> Element<'a, Message> { +/// Light-weight renderer for a single line of GitHub-style release-notes +/// markdown. Recognises: +/// * `## Heading` → bold accent line +/// * `### Heading` → smaller bold line +/// * `- bullet` → indented bullet text +/// * `**bold**` runs and `` `code` `` runs (rendered tonally, not styled +/// differently — iced's text widget has no inline run styling). +/// Anything else is plain body text. Strips the markdown markers so the +/// dialog reads cleanly even if the user has a Patreon-formatted note. +fn render_notes_line<'a>(raw: &str) -> Element<'a, Message> { + let trimmed = raw.trim_end(); + if trimmed.is_empty() { + return Space::new() + .height(iced::Length::Fixed(6.0)) + .into(); + } + if let Some(rest) = trimmed.strip_prefix("## ") { + return text(strip_inline_md(rest)) + .size(13) + .color(ACCENT) + .into(); + } + if let Some(rest) = trimmed.strip_prefix("### ") { + return text(strip_inline_md(rest)) + .size(12) + .color(WHITE) + .into(); + } + if let Some(rest) = trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* ")) { + return row![ + text("•").size(11).color(DIM).width(14), + text(strip_inline_md(rest)).size(11).color(WHITE), + ] + .spacing(4) + .into(); + } + text(strip_inline_md(trimmed)).size(11).color(WHITE).into() +} + +/// Drop `**…**` and `` `…` `` markers without preserving emphasis (iced 0.14 +/// Text widgets style the whole string uniformly). Keeps the inner text. +fn strip_inline_md(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '*' && chars.peek() == Some(&'*') { + chars.next(); + continue; + } + if c == '`' { + continue; + } + out.push(c); + } + out +} + +pub fn view_window<'a>(latest: &'a str, body: &'a str) -> Element<'a, Message> { let header = container( column![ text("New Release Available").size(20).color(ACCENT), @@ -63,31 +149,31 @@ pub fn view_window<'a>(latest: &'a str) -> Element<'a, Message> { ) .width(Fill) .padding(iced::Padding { - top: 16.0, + top: 14.0, right: 0.0, - bottom: 14.0, + bottom: 6.0, left: 0.0, }) .align_x(iced::Center); - let info_block = container( - column![ - info_row("Installed", format!("v{}", env!("CARGO_PKG_VERSION"))), - info_row("Latest", format!("v{}", latest)), - ] - .spacing(2) - .padding([12, 16]), - ) - .width(Fill) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL)), - border: Border { - color: BORDER, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() - }); + // Two side-by-side version cards (Installed → Latest) with the latest + // one accent-tinted to draw the eye. Replaces the previous label/value + // row layout. The arrow between them is purely decorative. + let installed = version_card( + "Installed", + format!("v{}", env!("CARGO_PKG_VERSION")), + false, + ); + let latest_card = version_card("Latest", format!("v{}", latest), true); + let arrow = container(text("→").size(22).color(DIM)) + .width(iced::Length::Fixed(32.0)) + .height(Fill) + .align_x(iced::Center) + .align_y(iced::Center); + let info_block = row![installed, arrow, latest_card] + .spacing(0) + .align_y(iced::Center) + .width(Fill); let later_btn = button(text("Later").size(11)) .on_press(Message::UpdateNoticeClose) @@ -147,9 +233,56 @@ pub fn view_window<'a>(latest: &'a str) -> Element<'a, Message> { left: 0.0, }); + // Release notes panel. Rendered as a light-markdown column inside a + // bordered scrollable so long bodies stay contained and don't + // explode the window. Empty body → "No release notes provided." + let notes_heading = container(text("What's new").size(11).color(DIM)) + .padding(iced::Padding { + top: 10.0, + right: 0.0, + bottom: 4.0, + left: 0.0, + }); + + let notes_body: Element<'a, Message> = if body.trim().is_empty() { + text("No release notes provided.") + .size(11) + .color(DIM) + .into() + } else { + let mut col = column![].spacing(4); + for line in body.lines() { + col = col.push(render_notes_line(line)); + } + scrollable(container(col).padding([10, 14])).height(Fill).into() + }; + + let notes_block = container(notes_body) + .width(Fill) + .height(Fill) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(PANEL)), + border: Border { + color: BORDER, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }); + + // Wrap notes_block in a Fill-height container outside the column so it + // greedily claims every pixel left over after the fixed-height rows + // (header, version cards, heading, footer). Without this iced lets the + // notes panel shrink to its content height, leaving a gap above the + // footer. + let notes_fill = container(notes_block) + .width(Fill) + .height(Fill); + container( - column![header, info_block, footer] + column![header, info_block, notes_heading, notes_fill, footer] .spacing(0) + .height(Fill) .padding(iced::Padding { top: 0.0, right: 20.0, diff --git a/src/update_check.rs b/src/update_check.rs index 6301e4d03..87582ffb3 100644 --- a/src/update_check.rs +++ b/src/update_check.rs @@ -3,7 +3,7 @@ // `check_for_update()` runs on a background thread (joined inside an // async wrapper, mirroring how the rest of `crate::io` wraps blocking // work for iced's `Task::perform`). It hits the GitHub releases API and -// returns `Some(latest_version)` when a newer release is available, or +// returns `Some(UpdateInfo)` when a newer release is available, or // `None` when up to date / on network failure / on parse error. const RELEASES_API: &str = @@ -11,14 +11,24 @@ const RELEASES_API: &str = pub const RELEASES_PAGE: &str = "https://github.com/HakanSeven12/H7CAD/releases/latest"; -pub async fn check_for_update() -> Option { +/// What `check_for_update` reports when a newer release exists. +#[derive(Debug, Clone)] +pub struct UpdateInfo { + /// `tag_name` with the leading `v` stripped (e.g. `0.3.7`). + pub version: String, + /// Release notes / markdown body from the GitHub release. May be empty + /// when the release was published without notes. + pub body: String, +} + +pub async fn check_for_update() -> Option { std::thread::spawn(fetch_latest_if_outdated) .join() .ok() .flatten() } -fn fetch_latest_if_outdated() -> Option { +fn fetch_latest_if_outdated() -> Option { let body = ureq::AgentBuilder::new() .timeout(std::time::Duration::from_secs(5)) .build() @@ -29,22 +39,71 @@ fn fetch_latest_if_outdated() -> Option { .ok()? .into_string() .ok()?; - let latest = extract_tag(&body)? + let latest = extract_string_field(&body, "tag_name")? .trim_start_matches('v') .to_string(); - if latest != env!("CARGO_PKG_VERSION") { - Some(latest) - } else { - None + if latest == env!("CARGO_PKG_VERSION") { + return None; } + // Release notes are optional; treat missing as empty. + let notes = extract_string_field(&body, "body").unwrap_or_default(); + Some(UpdateInfo { version: latest, body: notes }) } -fn extract_tag(body: &str) -> Option { - // Minimal substring extraction so we don't pull serde_json just for - // one field. The GitHub releases endpoint always returns - // `"tag_name":"v…"` near the top of the JSON object. - const KEY: &str = "\"tag_name\":\""; - let start = body.find(KEY)? + KEY.len(); - let end = body[start..].find('"')? + start; - Some(body[start..end].to_string()) +/// Minimal extractor for a top-level string field in the releases JSON. +/// Avoids pulling in `serde_json` for two fields. Handles standard JSON +/// string escapes (`\"`, `\\`, `\n`, `\r`, `\t`, `\/`) which are all the +/// GitHub release body uses in practice. +fn extract_string_field(body: &str, field: &str) -> Option { + let key = format!("\"{}\":\"", field); + let start = body.find(&key)? + key.len(); + // Walk to the closing unescaped `"`, JSON-unescaping as we go. + let mut out = String::new(); + let bytes = body.as_bytes(); + let mut i = start; + while i < bytes.len() { + let b = bytes[i]; + if b == b'"' { + return Some(out); + } + if b == b'\\' && i + 1 < bytes.len() { + match bytes[i + 1] { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => { + // \uXXXX — decode 4 hex digits. Surrogate pairs (BMP only + // for now) are uncommon in release notes; skip on parse + // failure. + if i + 5 < bytes.len() { + let hex = std::str::from_utf8(&bytes[i + 2..i + 6]).ok()?; + if let Ok(code) = u32::from_str_radix(hex, 16) { + if let Some(c) = char::from_u32(code) { + out.push(c); + } + } + i += 6; + continue; + } + return None; + } + other => { + // Unknown escape — keep the literal pair, GitHub doesn't emit these. + out.push('\\'); + out.push(other as char); + } + } + i += 2; + continue; + } + // UTF-8 multi-byte chars: push the whole code-point so we don't + // bisect a sequence. + let ch = body[i..].chars().next()?; + out.push(ch); + i += ch.len_utf8(); + } + None }