From 28d8246c68bdd17e507d4ed027900655774c6346 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 28 Jul 2026 11:49:45 +0300 Subject: [PATCH] feat(ui): add discussions and semantic theming Generate web discussion snapshots in CI; native builds use the public feed and an offline cache. --- .github/workflows/pages.yml | 55 ++++++ assets/icons/ui/dirty_dot.svg | 1 + index.html | 1 + src/app/config.rs | 6 +- src/app/mod.rs | 41 +++- src/app/update/mod.rs | 10 + src/app/view/mod.rs | 275 +++++++++++++++++++------- src/discussions.rs | 240 ++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 1 + src/ui/icons.rs | 363 +++++++++++++++++++++++++++++++++- src/ui/ribbon/mod.rs | 2 +- src/ui/ribbon/widgets.rs | 12 +- src/ui/side_toolbar.rs | 2 +- src/ui/window/layers.rs | 6 +- web/discussions.json | 42 ++++ 16 files changed, 960 insertions(+), 98 deletions(-) create mode 100644 assets/icons/ui/dirty_dot.svg create mode 100644 src/discussions.rs create mode 100644 web/discussions.json diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index dac21a5a9..7ca06f10f 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -7,10 +7,13 @@ run-name: ${{ github.event.release.tag_name || github.ref_name }} Web on: release: types: [published] + discussion: + types: [created, edited, deleted, transferred, pinned, unpinned, labeled, unlabeled, locked, unlocked, category_changed, answered, unanswered] workflow_dispatch: permissions: contents: read + discussions: read pages: write id-token: write @@ -79,6 +82,58 @@ jobs: > dist/supporters.json || echo '[]' > dist/supporters.json echo "supporters: $(jq 'length' dist/supporters.json)" + # GitHub's Discussions API is authenticated GraphQL. Generate a public, + # token-free snapshot next to the web app; discussion activity (including + # pin/unpin) triggers this workflow so pinned entries stay at the top. + - name: Generate discussions.json + env: + GH_TOKEN: ${{ github.token }} + run: | + QUERY=' + query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + pinnedDiscussions(first: 10) { + nodes { + discussion { + number title url updatedAt + author { login } + } + } + } + discussions(first: 50, orderBy: {field: UPDATED_AT, direction: DESC}) { + nodes { + number title url updatedAt + author { login } + } + } + } + }' + gh api graphql \ + -f query="$QUERY" \ + -F owner="$GITHUB_REPOSITORY_OWNER" \ + -F name="${GITHUB_REPOSITORY#*/}" \ + | jq -c ' + .data.repository as $repo + | [$repo.pinnedDiscussions.nodes[].discussion.number] as $pinned + | ( + [$repo.pinnedDiscussions.nodes[].discussion + {pinned: true}] + + [ + $repo.discussions.nodes[] + | select((.number as $number | $pinned | index($number)) == null) + | . + {pinned: false} + ] + ) + | map({ + number, + title, + url, + author: (.author.login // ""), + updated_at: .updatedAt, + pinned + })' \ + > dist/discussions.json + echo "discussions: $(jq 'length' dist/discussions.json)" + - uses: actions/upload-pages-artifact@v3 with: path: dist diff --git a/assets/icons/ui/dirty_dot.svg b/assets/icons/ui/dirty_dot.svg new file mode 100644 index 000000000..2af2700e8 --- /dev/null +++ b/assets/icons/ui/dirty_dot.svg @@ -0,0 +1 @@ + diff --git a/index.html b/index.html index bafd6c253..1bcf5c3bd 100644 --- a/index.html +++ b/index.html @@ -46,6 +46,7 @@ file) so CAD text renders non-Latin scripts on the web. (#141) --> +
diff --git a/src/app/config.rs b/src/app/config.rs index 7bc63e966..237071e88 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -56,7 +56,7 @@ pub struct UiThemeConfig { impl Default for UiThemeConfig { fn default() -> Self { - let theme = iced::Theme::Dark; + let theme = iced::Theme::Oxocarbon; Self { name: theme.to_string(), palette: UiThemePalette::from_iced(theme.palette()), @@ -69,7 +69,7 @@ impl UiThemeConfig { if self.name == "Custom" { iced::Theme::custom("Custom", self.palette.to_iced()) } else { - builtin_theme(&self.name).unwrap_or(iced::Theme::Dark) + builtin_theme(&self.name).unwrap_or(iced::Theme::Oxocarbon) } } } @@ -87,7 +87,7 @@ pub struct UiThemePalette { impl Default for UiThemePalette { fn default() -> Self { - Self::from_iced(iced::Theme::Dark.palette()) + Self::from_iced(iced::Theme::Oxocarbon.palette()) } } diff --git a/src/app/mod.rs b/src/app/mod.rs index b2e3833b8..001ae8760 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -226,6 +226,7 @@ pub enum StartSection { Videos, #[default] Welcome, + Discussions, Supporters, } @@ -259,6 +260,10 @@ pub(super) struct OpenCADStudio { video_thumbs: std::collections::HashMap, /// True while the boot-time playlist fetch is still in flight. videos_loading: bool, + /// GitHub Discussions shown on the Start page, with pinned entries first. + discussions: Vec, + /// True while the boot-time Discussions refresh is still in flight. + discussions_loading: bool, /// Block references whose properties panel shows per-axis Scale X/Y/Z even /// though the three factors are currently equal — the user unchecked the /// "Uniform scale" box for them (#427). Keyed by entity handle. @@ -2056,6 +2061,8 @@ pub enum Message { PatronsFetched(Result, String>), /// Tutorial-playlist videos fetched at boot for the Start page. VideosFetched(Result, String>), + /// GitHub Discussions fetched at boot for the Start page. + DiscussionsFetched(Result, String>), /// Recent-file DWG preview thumbnails decoded on a background thread. RecentThumbsLoaded( Vec<(std::path::PathBuf, Option)>, @@ -2483,6 +2490,8 @@ impl OpenCADStudio { videos: Vec::new(), video_thumbs: std::collections::HashMap::new(), videos_loading: false, + discussions: Vec::new(), + discussions_loading: false, props_asym_scale: std::collections::HashSet::new(), start_section: StartSection::default(), props_expanded: false, @@ -2630,8 +2639,8 @@ impl OpenCADStudio { default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(), // Plot style active_plot_style: None, - // Color scheme (default: dark CAD-style) - active_theme: Theme::Dark, + // Color scheme (default: Oxocarbon) + active_theme: Theme::Oxocarbon, ui_theme: config::UiThemeConfig::default(), theme_color_inputs: config::UiThemePalette::default().hex_values(), // Keyboard shortcuts @@ -2947,6 +2956,26 @@ impl OpenCADStudio { }; #[cfg(target_arch = "wasm32")] let videos_fetch = Task::none(); + // GitHub Discussions: seed from the last successful fetch, then refresh + // the public feed and pinned section on a background thread. + #[cfg(not(target_arch = "wasm32"))] + let discussions_fetch = { + s.discussions = crate::discussions::load_cached(); + s.discussions_loading = true; + let (tx, rx) = iced::futures::channel::oneshot::channel(); + std::thread::spawn(move || { + let _ = tx.send(crate::discussions::fetch_discussions()); + }); + Task::perform( + async move { + rx.await + .unwrap_or_else(|_| Err("discussion fetch thread died".into())) + }, + Message::DiscussionsFetched, + ) + }; + #[cfg(target_arch = "wasm32")] + let discussions_fetch = Task::none(); // Recent-file thumbnails: decoded off-thread — parsing every recent // DWG's preview on the boot path held the first frame back. let thumbs_fetch = s.refresh_recent_thumbs(); @@ -2961,6 +2990,7 @@ impl OpenCADStudio { assoc_prompt, patrons_fetch, videos_fetch, + discussions_fetch, thumbs_fetch, ]), ) @@ -2980,7 +3010,12 @@ impl OpenCADStudio { crate::patreon::fetch_patrons_web(), Message::PatronsFetched, ); - (s, Task::batch([focus, patrons])) + s.discussions_loading = true; + let discussions = Task::perform( + crate::discussions::fetch_discussions_web(), + Message::DiscussionsFetched, + ); + (s, Task::batch([focus, patrons, discussions])) } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index e4b31e846..ed11ad25b 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -3959,6 +3959,16 @@ impl OpenCADStudio { self.videos_loading = false; Task::none() } + Message::DiscussionsFetched(Ok(discussions)) => { + self.discussions_loading = false; + self.discussions = discussions; + Task::none() + } + // Offline: keep the native cache (web leaves the panel empty). + Message::DiscussionsFetched(Err(_)) => { + self.discussions_loading = false; + Task::none() + } Message::RecentThumbsLoaded(thumbs) => { for (path, handle) in thumbs { self.recent_thumbs.insert(path, handle); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index ea827b8a0..adeb902a9 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -121,6 +121,8 @@ impl OpenCADStudio { &self.videos, self.videos_loading, &self.video_thumbs, + &self.discussions, + self.discussions_loading, &self.recent_files, &self.recent_thumbs, self.recent_limit, @@ -1917,7 +1919,7 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele let name = crate::ui::text_util::elide(&tab.tab_display_name(), 24); let title_inner: Element<'_, Message> = if tab.dirty { row![ - crate::ui::icons::themed_warning(crate::ui::icons::DOT, 7.0), + crate::ui::icons::themed_warning(crate::ui::icons::DIRTY_DOT, 14.0), text(name).size(12), ] .spacing(5) @@ -1929,33 +1931,24 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele let title_btn = button(title_inner) .on_press(Message::TabSwitch(idx)) - .padding([5, 12]) + .height(Fill) + .padding([4, 12]) .style(move |theme: &Theme, status| { let palette = theme.extended_palette(); - let pair = if is_active { - palette.primary.weak - } else { - match status { - button::Status::Hovered => palette.background.weak, - _ => palette.background.base, + let background = match (is_active, status) { + (false, button::Status::Hovered) => { + Some(Background::Color(palette.background.weak.color)) } + _ => None, }; button::Style { - background: Some(Background::Color(pair.color)), + background, text_color: if is_active { - pair.text + palette.primary.weak.text } else { palette.background.base.text.scale_alpha(0.72) }, - border: Border { - color: if is_active { - palette.primary.base.color - } else { - Color::TRANSPARENT - }, - width: if is_active { 1.0 } else { 0.0 }, - radius: 0.0.into(), - }, + border: Border::default(), shadow: iced::Shadow::default(), snap: false, } @@ -1975,53 +1968,61 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele let row_inner: Row<'_, Message> = if tab.is_start { row![title_btn].spacing(0).align_y(iced::Center) } else { - let close_btn = button(crate::ui::icons::themed_secondary( - crate::ui::icons::CLOSE, - 10.0, - )) - .on_press(Message::TabClose(idx)) - .padding([3, 5]) - .style(move |theme: &Theme, status| { + let close_btn = button(text("×").size(12)) + .on_press(Message::TabClose(idx)) + .height(Fill) + .padding([4, 8]) + .style(button::subtle); + row![title_btn, close_btn] + .spacing(0) + .height(Fill) + .align_y(iced::Center) + }; + + let tab_container = container(row_inner) + .height(iced::Length::Fixed(23.0)) + .style(move |theme: &Theme| { let palette = theme.extended_palette(); - let pair = match status { - button::Status::Hovered | button::Status::Pressed => palette.danger.weak, - _ if is_active => palette.primary.weak, - _ => palette.background.base, - }; - button::Style { - background: Some(Background::Color(pair.color)), - text_color: pair.text, + container::Style { + background: Some(Background::Color(if is_active { + palette.primary.weak.color + } else { + palette.background.base.color + })), border: Border { - radius: 3.0.into(), - ..Default::default() + color: if is_active { + palette.primary.base.color + } else { + Color::TRANSPARENT + }, + width: if is_active { 1.0 } else { 0.0 }, + radius: 0.0.into(), }, ..Default::default() } }); - row![title_btn, close_btn].spacing(0).align_y(iced::Center) - }; - - let tab_container = container(row_inner).style(move |theme: &Theme| container::Style { - border: Border { - color: if is_active { - theme.extended_palette().background.neutral.color - } else { - Color::TRANSPARENT - }, - width: if is_active { 1.0 } else { 0.0 }, - radius: 0.0.into(), - }, - ..Default::default() - }); let tab_element: Element<'_, Message> = if tab.is_start { tab_container.into() } else { let has_current_path = tab.current_path.is_some(); let has_other_drawings = drag_targets.len() > 1; + let tab_target: Element<'_, Message> = if let Some(path) = &tab.current_path { + iced::widget::tooltip( + tab_container, + container(text(path.to_string_lossy().into_owned()).size(11)) + .style(container::bordered_box) + .padding([4, 8]), + iced::widget::tooltip::Position::Bottom, + ) + .gap(4) + .into() + } else { + tab_container.into() + }; crate::ui::wrap_bar::PosReport::owned( format!("DOC_TAB:{idx}"), - ContextMenu::new(tab_container, move || { + ContextMenu::new(tab_target, move || { doc_tab_context_menu(idx, has_current_path, has_other_drawings) }), ) @@ -2171,6 +2172,8 @@ pub(super) fn start_page_view<'a>( videos: &'a [crate::videos::VideoEntry], videos_loading: bool, video_thumbs: &'a std::collections::HashMap, + discussions: &'a [crate::discussions::DiscussionEntry], + discussions_loading: bool, recents: &'a [std::path::PathBuf], thumbs: &'a std::collections::HashMap< std::path::PathBuf, @@ -2301,24 +2304,21 @@ pub(super) fn start_page_view<'a>( .height(Fill); // Collapse side panels one at a time as width shrinks: Tutorials first, - // then Supporters, and Recent Documents last. The previous all-or-nothing - // threshold reserved a videos-sized empty margin on both sides of the - // centred Welcome block and hid every panel around 1664 px. + // then Discussions, Supporters, and Recent Documents last. #[derive(Clone, Copy, PartialEq, Eq)] enum StartLayout { AllPanels, WithoutVideos, + WithoutVideosAndDiscussions, RecentAndWelcome, Compact, } - let recent_w = 280.0f32; - let videos_w = 300.0f32; - let sup_w = 240.0f32; + let panel_w = 280.0f32; let welcome_wide_min = 360.0f32; let avail = (avail_w - 16.0).max(0.0); // minus the page's l/r padding - let panel_widths = [recent_w, videos_w, sup_w]; - let mut panel_visible = [true, true, true]; - let required_width = |visible: &[bool; 3]| { + let panel_widths = [panel_w; 4]; + let mut panel_visible = [true, true, true, true]; + let required_width = |visible: &[bool; 4]| { let visible_panels = visible.iter().filter(|&&shown| shown).count(); welcome_wide_min + panel_widths @@ -2330,16 +2330,17 @@ pub(super) fn start_page_view<'a>( }; // Re-measure after every collapse. There are no independent breakpoints: // the available width and the panels' preferred widths decide the state. - for panel in [1usize, 2, 0] { + for panel in [1usize, 2, 3, 0] { if required_width(&panel_visible) <= avail { break; } panel_visible[panel] = false; } let start_layout = match panel_visible { - [true, true, true] => StartLayout::AllPanels, - [true, false, true] => StartLayout::WithoutVideos, - [true, false, false] => StartLayout::RecentAndWelcome, + [true, true, true, true] => StartLayout::AllPanels, + [true, false, true, true] => StartLayout::WithoutVideos, + [true, false, false, true] => StartLayout::WithoutVideosAndDiscussions, + [true, false, false, false] => StartLayout::RecentAndWelcome, _ => StartLayout::Compact, }; @@ -2351,7 +2352,8 @@ pub(super) fn start_page_view<'a>( match start_layout { StartLayout::AllPanels | StartLayout::WithoutVideos - | StartLayout::RecentAndWelcome => iced::Length::Fixed(recent_w), + | StartLayout::WithoutVideosAndDiscussions + | StartLayout::RecentAndWelcome => iced::Length::Fixed(panel_w), StartLayout::Compact => iced::Length::Fill, }, ); @@ -2435,8 +2437,9 @@ pub(super) fn start_page_view<'a>( playlist_btn, ]) .width(match start_layout { - StartLayout::AllPanels => iced::Length::Fixed(videos_w), + StartLayout::AllPanels => iced::Length::Fixed(panel_w), StartLayout::WithoutVideos + | StartLayout::WithoutVideosAndDiscussions | StartLayout::RecentAndWelcome | StartLayout::Compact => iced::Length::Fill, }) @@ -2457,6 +2460,125 @@ pub(super) fn start_page_view<'a>( .into() }; + // GitHub Discussions rail. Native builds refresh from GitHub's public feed; + // web builds read the CI-generated snapshot. Both sources mark pinned + // discussions and sort them before the rest of the list. + let discussions_panel: Element<'a, Message> = { + let mut list = column![text("Discussions").size(15)] + .spacing(8) + .width(Fill); + for discussion in discussions { + let mut meta = iced::widget::row![ + text(format!("#{}", discussion.number)) + .size(10) + .style(start_muted_style), + ] + .spacing(6) + .align_y(iced::Center); + if discussion.pinned { + meta = meta.push( + text("Pinned") + .size(10) + .style(start_primary_style), + ); + } + if !discussion.author.is_empty() { + meta = meta.push( + text(format!("@{}", discussion.author)) + .size(10) + .style(start_muted_style), + ); + } + let card = container( + column![ + text(discussion.title.clone()).size(12), + meta, + ] + .spacing(4), + ) + .padding([8, 10]) + .width(Fill) + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color( + palette.background.base.color.scale_alpha(0.42), + )), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + } + }); + list = list.push( + mouse_area(card) + .interaction(iced::mouse::Interaction::Pointer) + .on_press(Message::OpenUrl(discussion.url.clone())), + ); + } + if discussions.is_empty() { + let note = if discussions_loading { + "Loading discussions…" + } else { + "Discussions load from GitHub." + }; + list = list.push(text(note).size(12).style(start_muted_style)); + } + let open_btn = mouse_area( + container(text("Open Discussions on GitHub").size(12)) + .padding([6, 10]) + .width(Fill) + .center_x(Fill) + .style(|theme: &Theme| { + let pair = theme.extended_palette().primary.base; + container::Style { + background: Some(Background::Color(pair.color)), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 6.0.into(), + }, + text_color: Some(pair.text), + ..Default::default() + } + }), + ) + .interaction(iced::mouse::Interaction::Pointer) + .on_press(Message::OpenUrl( + crate::discussions::DISCUSSIONS_URL.to_string(), + )); + container(column![ + iced::widget::scrollable(list).height(Fill), + Space::new().height(iced::Length::Fixed(12.0)), + open_btn, + ]) + .width(match start_layout { + StartLayout::AllPanels | StartLayout::WithoutVideos => { + iced::Length::Fixed(panel_w) + } + StartLayout::WithoutVideosAndDiscussions + | StartLayout::RecentAndWelcome + | StartLayout::Compact => iced::Length::Fill, + }) + .height(Fill) + .padding(16) + .style(|theme: &Theme| { + let palette = theme.extended_palette(); + container::Style { + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() + } + }) + .into() + }; + // Right rail: Patreon supporters, fetched at boot. When the list is empty // (no token configured / offline) only the "Support on Patreon" button // shows, so the rail always invites support. @@ -2515,8 +2637,10 @@ pub(super) fn start_page_view<'a>( support_btn, ]) .width(match start_layout { - StartLayout::AllPanels | StartLayout::WithoutVideos => { - iced::Length::Fixed(sup_w) + StartLayout::AllPanels + | StartLayout::WithoutVideos + | StartLayout::WithoutVideosAndDiscussions => { + iced::Length::Fixed(panel_w) } StartLayout::RecentAndWelcome | StartLayout::Compact => iced::Length::Fill, @@ -2543,12 +2667,19 @@ pub(super) fn start_page_view<'a>( recent, videos_panel, welcome, + discussions_panel, supporters, ] .spacing(16) .height(Fill) .into(), StartLayout::WithoutVideos => { + iced::widget::row![recent, welcome, discussions_panel, supporters] + .spacing(16) + .height(Fill) + .into() + } + StartLayout::WithoutVideosAndDiscussions => { iced::widget::row![recent, welcome, supporters] .spacing(16) .height(Fill) @@ -2595,6 +2726,7 @@ pub(super) fn start_page_view<'a>( tab_btn("Recent Files", super::StartSection::Recent).into(), tab_btn("Videos", super::StartSection::Videos).into(), tab_btn("Welcome", super::StartSection::Welcome).into(), + tab_btn("Discussions", super::StartSection::Discussions).into(), tab_btn("Supporters", super::StartSection::Supporters).into(), ]) .spacing_x(6.0) @@ -2611,6 +2743,11 @@ pub(super) fn start_page_view<'a>( .center_x(Fill) .into(), super::StartSection::Welcome => welcome.into(), + super::StartSection::Discussions => container(discussions_panel) + .width(Fill) + .height(Fill) + .center_x(Fill) + .into(), super::StartSection::Supporters => container(supporters) .width(Fill) .height(Fill) diff --git a/src/discussions.rs b/src/discussions.rs new file mode 100644 index 000000000..43b733122 --- /dev/null +++ b/src/discussions.rs @@ -0,0 +1,240 @@ +//! GitHub Discussions shown on the Start page. +//! +//! Native builds read GitHub's public Atom feed and the public Discussions +//! page, which exposes the repository's pinned-discussion section without an +//! access token. The last successful result is cached for offline launches. +//! Web builds read `discussions.json`, generated by the Pages workflow through +//! GitHub's authenticated GraphQL API. + +pub const DISCUSSIONS_URL: &str = + "https://github.com/HakanSeven12/OpenCADStudio/discussions"; + +#[cfg(not(target_arch = "wasm32"))] +const FEED_URL: &str = + "https://github.com/HakanSeven12/OpenCADStudio/discussions.atom"; + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct DiscussionEntry { + pub number: u64, + pub title: String, + pub url: String, + pub author: String, + pub updated_at: String, + #[serde(default)] + pub pinned: bool, +} + +fn sort_entries(entries: &mut [DiscussionEntry]) { + entries.sort_by(|a, b| { + b.pinned + .cmp(&a.pinned) + .then_with(|| b.updated_at.cmp(&a.updated_at)) + .then_with(|| b.number.cmp(&a.number)) + }); +} + +fn parse_json(body: &str) -> Result, String> { + let mut entries: Vec = + serde_json::from_str(body).map_err(|e| e.to_string())?; + entries.retain(|entry| { + entry.number > 0 + && !entry.title.trim().is_empty() + && entry.url.starts_with("https://github.com/") + }); + sort_entries(&mut entries); + Ok(entries) +} + +#[cfg(not(target_arch = "wasm32"))] +fn cache_path() -> Option { + crate::config::config_dir().map(|dir| dir.join("discussions.json")) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn load_cached() -> Vec { + cache_path() + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|body| parse_json(&body).ok()) + .or_else(|| parse_json(include_str!("../web/discussions.json")).ok()) + .unwrap_or_default() +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn fetch_discussions() -> Result, String> { + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(std::time::Duration::from_secs(15))) + .build() + .into(); + let feed = get_text(&agent, FEED_URL)?; + let page = get_text(&agent, DISCUSSIONS_URL)?; + let pinned = pinned_numbers(&page); + let mut entries = parse_atom(&feed); + if entries.is_empty() { + return Err("GitHub Discussions feed returned no entries".into()); + } + for entry in &mut entries { + entry.pinned = pinned.contains(&entry.number); + } + sort_entries(&mut entries); + + if let Some(path) = cache_path() { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(body) = serde_json::to_string(&entries) { + let _ = std::fs::write(path, body); + } + } + Ok(entries) +} + +#[cfg(not(target_arch = "wasm32"))] +fn get_text(agent: &ureq::Agent, url: &str) -> Result { + agent + .get(url) + .header( + "User-Agent", + concat!("OpenCADStudio/", env!("CARGO_PKG_VERSION")), + ) + .call() + .map_err(|e| e.to_string())? + .body_mut() + .with_config() + .limit(4 * 1024 * 1024) + .read_to_string() + .map_err(|e| e.to_string()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn parse_atom(feed: &str) -> Vec { + feed.split("") + .skip(1) + .filter_map(|tail| { + let entry = tail.split_once("")?.0; + let url_start = entry.find( + "href=\"https://github.com/HakanSeven12/OpenCADStudio/discussions/", + )? + "href=\"".len(); + let url_tail = &entry[url_start..]; + let url = url_tail.split_once('"')?.0.to_string(); + let number = url.rsplit('/').next()?.parse().ok()?; + let title = xml_text(entry, "title")?; + let author_block = entry.split_once("")?.1.split_once("")?.0; + let author = xml_text(author_block, "name").unwrap_or_default(); + let updated_at = xml_text(entry, "updated").unwrap_or_default(); + Some(DiscussionEntry { + number, + title, + url, + author, + updated_at, + pinned: false, + }) + }) + .collect() +} + +#[cfg(not(target_arch = "wasm32"))] +fn xml_text(block: &str, tag: &str) -> Option { + let open = format!("<{tag}>"); + let close = format!(""); + let value = block.split_once(&open)?.1.split_once(&close)?.0; + let compact = value.split_whitespace().collect::>().join(" "); + if compact.is_empty() { + None + } else { + Some( + compact + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("<", "<") + .replace(">", ">") + .replace("&", "&"), + ) + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn pinned_numbers(page: &str) -> std::collections::HashSet { + let mut numbers = std::collections::HashSet::new(); + let Some(after_heading) = page.split_once("id=\"pinned-discussions\"").map(|(_, rest)| rest) + else { + return numbers; + }; + let section = after_heading + .split_once("") + .map(|(section, _)| section) + .unwrap_or(after_heading); + let marker = "/HakanSeven12/OpenCADStudio/discussions/"; + let mut rest = section; + while let Some(pos) = rest.find(marker) { + let tail = &rest[pos + marker.len()..]; + let digits = tail + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .collect::(); + if let Ok(number) = digits.parse() { + numbers.insert(number); + } + rest = tail; + } + numbers +} + +#[cfg(target_arch = "wasm32")] +pub async fn fetch_discussions_web() -> Result, String> { + use wasm_bindgen::JsCast; + use wasm_bindgen_futures::JsFuture; + + let window = web_sys::window().ok_or("no window")?; + let response = JsFuture::from(window.fetch_with_str("discussions.json")) + .await + .map_err(|_| "fetch failed")?; + let response: web_sys::Response = + response.dyn_into().map_err(|_| "not a Response")?; + if !response.ok() { + return Err(format!("HTTP {}", response.status())); + } + let text = JsFuture::from(response.text().map_err(|_| "text() unavailable")?) + .await + .map_err(|_| "body read failed")?; + let body = text.as_string().ok_or("body is not a string")?; + parse_json(&body) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn pinned_discussions_are_sorted_first() { + let feed = r#" + + + New & recent + 2026-07-28T10:00:00+00:00 + alice + + + + Pinned + 2026-07-20T10:00:00+00:00 + bob + + "#; + let page = r#" +

Pinned Discussions

+ + "#; + let pinned = pinned_numbers(page); + let mut entries = parse_atom(feed); + for entry in &mut entries { + entry.pinned = pinned.contains(&entry.number); + } + sort_entries(&mut entries); + + assert_eq!(entries[0].number, 1); + assert_eq!(entries[1].title, "New & recent"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 64369e432..ea1711c07 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod entities; pub mod io; pub mod modules; pub mod patreon; +pub mod discussions; pub mod videos; pub mod plugin; pub mod perf; diff --git a/src/main.rs b/src/main.rs index 783e5109b..c92a56c8f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod entities; mod io; mod modules; mod patreon; +mod discussions; mod videos; mod plugin; mod perf; diff --git a/src/ui/icons.rs b/src/ui/icons.rs index b57a56c5d..50aa23db6 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -1,4 +1,4 @@ -//! Shared monochrome UI-chrome icons rendered from bundled SVGs. +//! Shared SVG rendering for monochrome UI chrome and multi-colour tool icons. //! //! Dropdown carets and the undo/redo controls used to be drawn as Unicode //! glyphs (`▾`, `▲`, `↶`, `↷`). Those depend on the active text font carrying @@ -6,8 +6,18 @@ //! build bundles only Fira Sans, which lacks them, so they rendered as empty //! boxes. Drawing them from SVG instead makes the chrome font-independent. +use std::cell::RefCell; + +use iced::advanced::layout::{self, Layout}; +use iced::advanced::renderer; +use iced::advanced::svg::{self as core_svg, Renderer as _}; +use iced::advanced::widget::{Tree, Widget}; use iced::widget::{container, svg, Space}; -use iced::{Element, Length, Theme}; +use iced::{ + Color, ContentFit, Element, Length, Point, Radians, Rectangle, Renderer, + Size, Theme, +}; +use rustc_hash::FxHashMap; const TRI_DOWN: &[u8] = include_bytes!("../../assets/icons/ui/tri_down.svg"); const TRI_UP: &[u8] = include_bytes!("../../assets/icons/ui/tri_up.svg"); @@ -65,6 +75,7 @@ pub const FILE_EXPORT: &[u8] = include_bytes!("../../assets/icons/ui/file_export pub const PRINT: &[u8] = include_bytes!("../../assets/icons/ui/print.svg"); pub const HEART: &[u8] = include_bytes!("../../assets/icons/ui/heart.svg"); pub const DOT: &[u8] = include_bytes!("../../assets/icons/ui/dot.svg"); +pub const DIRTY_DOT: &[u8] = include_bytes!("../../assets/icons/ui/dirty_dot.svg"); pub const ARROW_LONG_RIGHT: &[u8] = include_bytes!("../../assets/icons/ui/arrow_long_right.svg"); // ── Status-bar toggle icons (issue #216) ────────────────────────────────── @@ -81,6 +92,303 @@ pub const ST_FILTER: &[u8] = include_bytes!("../../assets/icons/status/filter.sv pub const ST_SELCYCLE: &[u8] = include_bytes!("../../assets/icons/status/selcycle.svg"); pub const ST_CLEANSCREEN: &[u8] = include_bytes!("../../assets/icons/status/cleanscreen.svg"); +// Tool SVGs share a small source palette. These colours are semantic rather +// than literal: cyan is the accent, pale grey is foreground, yellow is warning, +// and so on. `SemanticIcon` resolves those roles from Iced's active extended +// palette at draw time, retaining the artwork's multiple colours across themes. +const SEMANTIC_CACHE_LIMIT: usize = 2048; + +#[derive(Clone, Copy, Hash, PartialEq, Eq)] +struct SemanticCacheKey { + address: usize, + length: usize, + palette: [[u8; 4]; 6], +} + +thread_local! { + static SEMANTIC_CACHE: RefCell> = + RefCell::new(FxHashMap::default()); +} + +#[derive(Clone, Copy)] +struct SemanticColors { + background: [u8; 7], + text: [u8; 7], + primary_weak: [u8; 7], + primary: [u8; 7], + primary_strong: [u8; 7], + secondary_weak: [u8; 7], + secondary: [u8; 7], + secondary_strong: [u8; 7], + success_weak: [u8; 7], + success: [u8; 7], + warning: [u8; 7], + warning_strong: [u8; 7], + danger_weak: [u8; 7], + danger: [u8; 7], +} + +impl SemanticColors { + fn from_theme(theme: &Theme) -> Self { + let palette = theme.extended_palette(); + Self { + background: color_hex(palette.background.strong.color), + text: color_hex(palette.background.base.text), + primary_weak: color_hex(palette.primary.weak.color), + primary: color_hex(palette.primary.base.color), + primary_strong: color_hex(palette.primary.strong.color), + secondary_weak: color_hex(palette.secondary.weak.color), + secondary: color_hex(palette.secondary.base.color), + secondary_strong: color_hex(palette.secondary.strong.color), + success_weak: color_hex(palette.success.weak.color), + success: color_hex(palette.success.base.color), + warning: color_hex(palette.warning.base.color), + warning_strong: color_hex(palette.warning.strong.color), + danger_weak: color_hex(palette.danger.weak.color), + danger: color_hex(palette.danger.base.color), + } + } +} + +struct SemanticIcon { + bytes: &'static [u8], + size: f32, + opacity: f32, +} + +impl Widget for SemanticIcon { + fn size(&self) -> Size { + Size::new(Length::Fixed(self.size), Length::Fixed(self.size)) + } + + fn layout( + &mut self, + _tree: &mut Tree, + _renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + layout::atomic( + limits, + Length::Fixed(self.size), + Length::Fixed(self.size), + ) + } + + fn draw( + &self, + _tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: iced::advanced::mouse::Cursor, + _viewport: &Rectangle, + ) { + let handle = semantic_handle(self.bytes, theme); + let measured = renderer.measure_svg(&handle); + if measured.width == 0 || measured.height == 0 { + return; + } + + let image_size = Size::new(measured.width as f32, measured.height as f32); + let bounds = layout.bounds(); + let fitted = ContentFit::Contain.fit(image_size, bounds.size()); + let position = Point::new( + bounds.center_x() - fitted.width / 2.0, + bounds.center_y() - fitted.height / 2.0, + ); + + renderer.draw_svg( + core_svg::Svg { + handle, + color: None, + rotation: Radians(0.0), + opacity: self.opacity, + }, + Rectangle::new(position, fitted), + bounds, + ); + } +} + +/// Render a multi-colour tool icon using semantic colours from the active theme. +pub fn semantic<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { + Element::new(SemanticIcon { + bytes, + size, + opacity: 1.0, + }) +} + +/// Render a disabled multi-colour tool icon without flattening its colour roles. +pub fn semantic_disabled<'a, M: 'a>( + bytes: &'static [u8], + size: f32, +) -> Element<'a, M> { + Element::new(SemanticIcon { + bytes, + size, + opacity: 0.42, + }) +} + +fn semantic_handle(bytes: &'static [u8], theme: &Theme) -> svg::Handle { + let key = SemanticCacheKey { + address: bytes.as_ptr() as usize, + length: bytes.len(), + palette: palette_key(theme), + }; + + SEMANTIC_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + if let Some(handle) = cache.get(&key) { + return handle.clone(); + } + + let handle = svg::Handle::from_memory(recolor_semantic_svg(bytes, theme)); + if cache.len() >= SEMANTIC_CACHE_LIMIT { + cache.clear(); + } + cache.insert(key, handle.clone()); + handle + }) +} + +fn palette_key(theme: &Theme) -> [[u8; 4]; 6] { + let palette = theme.palette(); + [ + palette.background.into_rgba8(), + palette.text.into_rgba8(), + palette.primary.into_rgba8(), + palette.success.into_rgba8(), + palette.warning.into_rgba8(), + palette.danger.into_rgba8(), + ] +} + +fn recolor_semantic_svg(source: &[u8], theme: &Theme) -> Vec { + let colors = SemanticColors::from_theme(theme); + let mut output = Vec::with_capacity(source.len()); + let mut index = 0; + + while index < source.len() { + if source[index] == b'#' { + let mut end = index + 1; + while end < source.len() && source[end].is_ascii_hexdigit() { + end += 1; + } + let digit_count = end - index - 1; + if matches!(digit_count, 3 | 4 | 6 | 8) { + if let Some(replacement) = + semantic_color(&source[index..end], &colors) + { + output.extend_from_slice(replacement); + index = end; + continue; + } + } + } else if starts_with_word_ignore_ascii_case(source, index, b"white") { + output.extend_from_slice(&colors.text); + index += 5; + continue; + } + + output.push(source[index]); + index += 1; + } + + output +} + +fn semantic_color<'a>( + token: &[u8], + colors: &'a SemanticColors, +) -> Option<&'a [u8; 7]> { + if is_one_of(token, &["#e0e0e0", "#eeeeee", "#ffffff", "#e1e1e1"]) { + Some(&colors.text) + } else if is_one_of(token, &["#cccccc", "#bdbdbd", "#aaaaaa"]) { + Some(&colors.secondary_strong) + } else if is_one_of( + token, + &["#888888", "#888", "#9e9e9e", "#90a4ae", "#78909c", "#7a7a7a"], + ) { + Some(&colors.secondary) + } else if is_one_of( + token, + &[ + "#505050", "#555", "#606060", "#666", "#616161", "#546e7a", + "#455a64", "#37474f", + ], + ) { + Some(&colors.secondary_weak) + } else if is_one_of(token, &["#1a1a1a"]) { + Some(&colors.background) + } else if is_one_of(token, &["#4cc9f0", "#4bc8f0", "#4a9eff", "#0099e5"]) { + Some(&colors.primary) + } else if is_one_of(token, &["#1565c0"]) { + Some(&colors.primary_strong) + } else if is_one_of(token, &["#0d47a1"]) { + Some(&colors.primary_weak) + } else if is_one_of(token, &["#4ccf6f"]) { + Some(&colors.success) + } else if is_one_of(token, &["#00695c", "#004d40"]) { + Some(&colors.success_weak) + } else if is_one_of(token, &["#f0c040", "#ffd740", "#fdd835"]) { + Some(&colors.warning) + } else if is_one_of(token, &["#f9a825"]) { + Some(&colors.warning_strong) + } else if is_one_of( + token, + &[ + "#e05050", "#ef5350", "#e06c6c", "#e53935", "#ff0000", "#e10000", + ], + ) { + Some(&colors.danger) + } else if is_one_of(token, &["#b71c1c"]) { + Some(&colors.danger_weak) + } else { + None + } +} + +fn is_one_of(token: &[u8], candidates: &[&str]) -> bool { + candidates + .iter() + .any(|candidate| token.eq_ignore_ascii_case(candidate.as_bytes())) +} + +fn starts_with_word_ignore_ascii_case( + source: &[u8], + index: usize, + word: &[u8], +) -> bool { + let Some(end) = index.checked_add(word.len()) else { + return false; + }; + if end > source.len() + || !source[index..end].eq_ignore_ascii_case(word) + || index > 0 && source[index - 1].is_ascii_alphabetic() + || end < source.len() && source[end].is_ascii_alphabetic() + { + return false; + } + true +} + +fn color_hex(color: Color) -> [u8; 7] { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let [red, green, blue, _] = color.into_rgba8(); + [ + b'#', + HEX[(red >> 4) as usize], + HEX[(red & 0x0f) as usize], + HEX[(green >> 4) as usize], + HEX[(green & 0x0f) as usize], + HEX[(blue >> 4) as usize], + HEX[(blue & 0x0f) as usize], + ] +} + /// Render a chrome icon with the active Iced theme's normal text color. pub fn themed<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { svg(svg::Handle::from_memory(bytes)) @@ -193,14 +501,6 @@ pub fn themed_check_cell<'a, M: 'a>(active: bool) -> Element<'a, M> { container(inner).width(Length::Fixed(14.0)).into() } -/// Render a bundled SVG at its native colours (no tint) at a square `size`. -pub fn raw<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> { - svg(svg::Handle::from_memory(bytes)) - .width(size) - .height(size) - .into() -} - /// SVG bytes for an OSNAP mode's marker symbol, for the snap menu. (#138) pub fn osnap(snap: crate::snap::SnapType) -> &'static [u8] { use crate::snap::SnapType as S; @@ -307,3 +607,46 @@ pub fn themed_redo<'a, M: 'a>(size: f32, enabled: bool) -> Element<'a, M> { themed_disabled(REDO, size) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn semantic_svg_uses_multiple_theme_roles() { + let source = br##" + + + + + + + "##; + let theme = Theme::Dark; + let colors = SemanticColors::from_theme(&theme); + let themed = recolor_semantic_svg(source, &theme); + + assert!(contains(&themed, &colors.text)); + assert!(contains(&themed, &colors.primary)); + assert!(contains(&themed, &colors.warning)); + assert!(contains(&themed, &colors.danger)); + assert!(contains(&themed, &colors.success)); + assert!(contains(&themed, b"#123456")); + } + + #[test] + fn semantic_svg_maps_named_white() { + let theme = Theme::Dark; + let colors = SemanticColors::from_theme(&theme); + let themed = + recolor_semantic_svg(br##""##, &theme); + + assert!(contains(&themed, &colors.text)); + } + + fn contains(source: &[u8], needle: &[u8]) -> bool { + source + .windows(needle.len()) + .any(|window| window == needle) + } +} diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 233b9bd28..85190521e 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -869,7 +869,7 @@ impl Ribbon { // on it flips that state instead of bubbling up to the row's // make-active handler (#133). let icon_btn = |bytes: &'static [u8], msg: Message| -> Element<'_, Message> { - button(crate::ui::icons::raw(bytes, 14.0)) + button(crate::ui::icons::semantic(bytes, 14.0)) .on_press(msg) .style(popup_row_style) .padding([2, 4]) diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index 4ad059b90..d6b3b0dd7 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -126,7 +126,7 @@ pub(super) fn flush_small_col<'a>( pub(super) fn make_icon(icon: IconKind, size: f32) -> Element<'static, Message> { match icon { IconKind::Glyph(s) => text(s).size(size * 0.7).into(), - IconKind::Svg(bytes) => icons::themed(bytes, size), + IconKind::Svg(bytes) => icons::semantic(bytes, size), } } @@ -137,7 +137,7 @@ pub(super) fn start_dimmed(state: &ToggleState, event: &ModuleEvent) -> bool { && !matches!(event, ModuleEvent::Command(c) if crate::app::commands::start_allowed(c)) } -/// `make_icon`, greyed out when `dim` (SVGs render monochrome via tint). +/// `make_icon`, faded when `dim` without flattening multi-colour SVGs. pub(super) fn make_icon_dim(icon: IconKind, size: f32, dim: bool) -> Element<'static, Message> { if !dim { return make_icon(icon, size); @@ -149,7 +149,7 @@ pub(super) fn make_icon_dim(icon: IconKind, size: f32, dim: bool) -> Element<'st color: Some(theme.extended_palette().background.base.text.scale_alpha(0.42)), }) .into(), - IconKind::Svg(bytes) => icons::themed_disabled(bytes, size), + IconKind::Svg(bytes) => icons::semantic_disabled(bytes, size), } } @@ -625,9 +625,9 @@ pub(super) fn render_large<'a>( let ll = info.map(|l| l.locked).unwrap_or(false); let is_open = open_dd.as_deref() == Some(LAYER_COMBO_ID); - let vis_icon = icons::raw(icons::layer_visible(lv), 14.0); - let freeze_icon = icons::raw(icons::layer_freeze(lf), 14.0); - let lock_icon = icons::raw(icons::layer_lock(ll), 14.0); + let vis_icon = icons::semantic(icons::layer_visible(lv), 14.0); + let freeze_icon = icons::semantic(icons::layer_freeze(lf), 14.0); + let lock_icon = icons::semantic(icons::layer_lock(ll), 14.0); let swatch = container(text("")) .style(move |theme: &Theme| container::Style { background: Some(Background::Color(lc)), diff --git a/src/ui/side_toolbar.rs b/src/ui/side_toolbar.rs index db01ebb1b..9bad95bf6 100644 --- a/src/ui/side_toolbar.rs +++ b/src/ui/side_toolbar.rs @@ -21,7 +21,7 @@ const EDGE_MARGIN: f32 = 8.0; fn icon_el(icon: IconKind) -> Element<'static, Message> { match icon { IconKind::Glyph(s) => text(s).size(ICON_SIZE * 0.85).into(), - IconKind::Svg(bytes) => crate::ui::icons::themed(bytes, ICON_SIZE), + IconKind::Svg(bytes) => crate::ui::icons::semantic(bytes, ICON_SIZE), } } diff --git a/src/ui/window/layers.rs b/src/ui/window/layers.rs index 7f45db66e..1aa5aabec 100644 --- a/src/ui/window/layers.rs +++ b/src/ui/window/layers.rs @@ -653,11 +653,7 @@ fn layer_row<'a>( name_col_w: f32, ) -> Element<'a, Message> { let svg_btn = |bytes: &'static [u8], on_press: Message| -> Element<'a, Message> { - button( - iced::widget::svg(iced::widget::svg::Handle::from_memory(bytes)) - .width(ICON_SZ) - .height(ICON_SZ), - ) + button(crate::ui::icons::semantic(bytes, ICON_SZ)) .on_press(on_press) .style(|theme: &Theme, status| button::Style { background: matches!(status, button::Status::Hovered).then_some( diff --git a/web/discussions.json b/web/discussions.json new file mode 100644 index 000000000..a08409e91 --- /dev/null +++ b/web/discussions.json @@ -0,0 +1,42 @@ +[ + { + "number": 531, + "title": "Choose The Default Theme", + "url": "https://github.com/HakanSeven12/OpenCADStudio/discussions/531", + "author": "HakanSeven12", + "updated_at": "2026-07-28T07:21:51Z", + "pinned": true + }, + { + "number": 518, + "title": "Addin ideas", + "url": "https://github.com/HakanSeven12/OpenCADStudio/discussions/518", + "author": "UserDevtec", + "updated_at": "2026-07-27T14:09:44Z", + "pinned": false + }, + { + "number": 430, + "title": "Inline Python REPL", + "url": "https://github.com/HakanSeven12/OpenCADStudio/discussions/430", + "author": "schoeller", + "updated_at": "2026-07-24T16:13:26Z", + "pinned": false + }, + { + "number": 214, + "title": "New improvements", + "url": "https://github.com/HakanSeven12/OpenCADStudio/discussions/214", + "author": "JuanJoseJimenezdeCisneros", + "updated_at": "2026-07-14T14:10:11Z", + "pinned": false + }, + { + "number": 382, + "title": "macOS 27 Golden Gate - Public Beta", + "url": "https://github.com/HakanSeven12/OpenCADStudio/discussions/382", + "author": "ZoomerHub", + "updated_at": "2026-07-14T12:57:50Z", + "pinned": false + } +]