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!("{tag}>");
+ 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
+ }
+]