feat(ui): add discussions and semantic theming
Generate web discussion snapshots in CI; native builds use the public feed and an offline cache.
This commit is contained in:
parent
4bf14baea4
commit
28d8246c68
16 changed files with 960 additions and 98 deletions
55
.github/workflows/pages.yml
vendored
55
.github/workflows/pages.yml
vendored
|
|
@ -7,10 +7,13 @@ run-name: ${{ github.event.release.tag_name || github.ref_name }} Web
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
|
discussion:
|
||||||
|
types: [created, edited, deleted, transferred, pinned, unpinned, labeled, unlabeled, locked, unlocked, category_changed, answered, unanswered]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
discussions: read
|
||||||
pages: write
|
pages: write
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
|
|
@ -79,6 +82,58 @@ jobs:
|
||||||
> dist/supporters.json || echo '[]' > dist/supporters.json
|
> dist/supporters.json || echo '[]' > dist/supporters.json
|
||||||
echo "supporters: $(jq 'length' 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
|
- uses: actions/upload-pages-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: dist
|
path: dist
|
||||||
|
|
|
||||||
1
assets/icons/ui/dirty_dot.svg
Normal file
1
assets/icons/ui/dirty_dot.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="6.75 6.75 10.5 10.5" fill="#000000" stroke="none"><circle cx="12" cy="12" r="4.5"/></svg>
|
||||||
|
After Width: | Height: | Size: 139 B |
|
|
@ -46,6 +46,7 @@
|
||||||
file) so CAD text renders non-Latin scripts on the web. (#141) -->
|
file) so CAD text renders non-Latin scripts on the web. (#141) -->
|
||||||
<link data-trunk rel="copy-dir" href="web/fonts" />
|
<link data-trunk rel="copy-dir" href="web/fonts" />
|
||||||
<link data-trunk rel="copy-file" href="web/ocs-parse-worker.js" />
|
<link data-trunk rel="copy-file" href="web/ocs-parse-worker.js" />
|
||||||
|
<link data-trunk rel="copy-file" href="web/discussions.json" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="loading">
|
<div id="loading">
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ pub struct UiThemeConfig {
|
||||||
|
|
||||||
impl Default for UiThemeConfig {
|
impl Default for UiThemeConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let theme = iced::Theme::Dark;
|
let theme = iced::Theme::Oxocarbon;
|
||||||
Self {
|
Self {
|
||||||
name: theme.to_string(),
|
name: theme.to_string(),
|
||||||
palette: UiThemePalette::from_iced(theme.palette()),
|
palette: UiThemePalette::from_iced(theme.palette()),
|
||||||
|
|
@ -69,7 +69,7 @@ impl UiThemeConfig {
|
||||||
if self.name == "Custom" {
|
if self.name == "Custom" {
|
||||||
iced::Theme::custom("Custom", self.palette.to_iced())
|
iced::Theme::custom("Custom", self.palette.to_iced())
|
||||||
} else {
|
} 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 {
|
impl Default for UiThemePalette {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::from_iced(iced::Theme::Dark.palette())
|
Self::from_iced(iced::Theme::Oxocarbon.palette())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,7 @@ pub enum StartSection {
|
||||||
Videos,
|
Videos,
|
||||||
#[default]
|
#[default]
|
||||||
Welcome,
|
Welcome,
|
||||||
|
Discussions,
|
||||||
Supporters,
|
Supporters,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -259,6 +260,10 @@ pub(super) struct OpenCADStudio {
|
||||||
video_thumbs: std::collections::HashMap<String, iced::widget::image::Handle>,
|
video_thumbs: std::collections::HashMap<String, iced::widget::image::Handle>,
|
||||||
/// True while the boot-time playlist fetch is still in flight.
|
/// True while the boot-time playlist fetch is still in flight.
|
||||||
videos_loading: bool,
|
videos_loading: bool,
|
||||||
|
/// GitHub Discussions shown on the Start page, with pinned entries first.
|
||||||
|
discussions: Vec<crate::discussions::DiscussionEntry>,
|
||||||
|
/// 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
|
/// Block references whose properties panel shows per-axis Scale X/Y/Z even
|
||||||
/// though the three factors are currently equal — the user unchecked the
|
/// though the three factors are currently equal — the user unchecked the
|
||||||
/// "Uniform scale" box for them (#427). Keyed by entity handle.
|
/// "Uniform scale" box for them (#427). Keyed by entity handle.
|
||||||
|
|
@ -2056,6 +2061,8 @@ pub enum Message {
|
||||||
PatronsFetched(Result<Vec<(String, i64)>, String>),
|
PatronsFetched(Result<Vec<(String, i64)>, String>),
|
||||||
/// Tutorial-playlist videos fetched at boot for the Start page.
|
/// Tutorial-playlist videos fetched at boot for the Start page.
|
||||||
VideosFetched(Result<Vec<crate::videos::VideoEntry>, String>),
|
VideosFetched(Result<Vec<crate::videos::VideoEntry>, String>),
|
||||||
|
/// GitHub Discussions fetched at boot for the Start page.
|
||||||
|
DiscussionsFetched(Result<Vec<crate::discussions::DiscussionEntry>, String>),
|
||||||
/// Recent-file DWG preview thumbnails decoded on a background thread.
|
/// Recent-file DWG preview thumbnails decoded on a background thread.
|
||||||
RecentThumbsLoaded(
|
RecentThumbsLoaded(
|
||||||
Vec<(std::path::PathBuf, Option<iced::widget::image::Handle>)>,
|
Vec<(std::path::PathBuf, Option<iced::widget::image::Handle>)>,
|
||||||
|
|
@ -2483,6 +2490,8 @@ impl OpenCADStudio {
|
||||||
videos: Vec::new(),
|
videos: Vec::new(),
|
||||||
video_thumbs: std::collections::HashMap::new(),
|
video_thumbs: std::collections::HashMap::new(),
|
||||||
videos_loading: false,
|
videos_loading: false,
|
||||||
|
discussions: Vec::new(),
|
||||||
|
discussions_loading: false,
|
||||||
props_asym_scale: std::collections::HashSet::new(),
|
props_asym_scale: std::collections::HashSet::new(),
|
||||||
start_section: StartSection::default(),
|
start_section: StartSection::default(),
|
||||||
props_expanded: false,
|
props_expanded: false,
|
||||||
|
|
@ -2630,8 +2639,8 @@ impl OpenCADStudio {
|
||||||
default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(),
|
default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(),
|
||||||
// Plot style
|
// Plot style
|
||||||
active_plot_style: None,
|
active_plot_style: None,
|
||||||
// Color scheme (default: dark CAD-style)
|
// Color scheme (default: Oxocarbon)
|
||||||
active_theme: Theme::Dark,
|
active_theme: Theme::Oxocarbon,
|
||||||
ui_theme: config::UiThemeConfig::default(),
|
ui_theme: config::UiThemeConfig::default(),
|
||||||
theme_color_inputs: config::UiThemePalette::default().hex_values(),
|
theme_color_inputs: config::UiThemePalette::default().hex_values(),
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
|
|
@ -2947,6 +2956,26 @@ impl OpenCADStudio {
|
||||||
};
|
};
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
let videos_fetch = Task::none();
|
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
|
// Recent-file thumbnails: decoded off-thread — parsing every recent
|
||||||
// DWG's preview on the boot path held the first frame back.
|
// DWG's preview on the boot path held the first frame back.
|
||||||
let thumbs_fetch = s.refresh_recent_thumbs();
|
let thumbs_fetch = s.refresh_recent_thumbs();
|
||||||
|
|
@ -2961,6 +2990,7 @@ impl OpenCADStudio {
|
||||||
assoc_prompt,
|
assoc_prompt,
|
||||||
patrons_fetch,
|
patrons_fetch,
|
||||||
videos_fetch,
|
videos_fetch,
|
||||||
|
discussions_fetch,
|
||||||
thumbs_fetch,
|
thumbs_fetch,
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
|
|
@ -2980,7 +3010,12 @@ impl OpenCADStudio {
|
||||||
crate::patreon::fetch_patrons_web(),
|
crate::patreon::fetch_patrons_web(),
|
||||||
Message::PatronsFetched,
|
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]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3959,6 +3959,16 @@ impl OpenCADStudio {
|
||||||
self.videos_loading = false;
|
self.videos_loading = false;
|
||||||
Task::none()
|
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) => {
|
Message::RecentThumbsLoaded(thumbs) => {
|
||||||
for (path, handle) in thumbs {
|
for (path, handle) in thumbs {
|
||||||
self.recent_thumbs.insert(path, handle);
|
self.recent_thumbs.insert(path, handle);
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,8 @@ impl OpenCADStudio {
|
||||||
&self.videos,
|
&self.videos,
|
||||||
self.videos_loading,
|
self.videos_loading,
|
||||||
&self.video_thumbs,
|
&self.video_thumbs,
|
||||||
|
&self.discussions,
|
||||||
|
self.discussions_loading,
|
||||||
&self.recent_files,
|
&self.recent_files,
|
||||||
&self.recent_thumbs,
|
&self.recent_thumbs,
|
||||||
self.recent_limit,
|
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 name = crate::ui::text_util::elide(&tab.tab_display_name(), 24);
|
||||||
let title_inner: Element<'_, Message> = if tab.dirty {
|
let title_inner: Element<'_, Message> = if tab.dirty {
|
||||||
row![
|
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),
|
text(name).size(12),
|
||||||
]
|
]
|
||||||
.spacing(5)
|
.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)
|
let title_btn = button(title_inner)
|
||||||
.on_press(Message::TabSwitch(idx))
|
.on_press(Message::TabSwitch(idx))
|
||||||
.padding([5, 12])
|
.height(Fill)
|
||||||
|
.padding([4, 12])
|
||||||
.style(move |theme: &Theme, status| {
|
.style(move |theme: &Theme, status| {
|
||||||
let palette = theme.extended_palette();
|
let palette = theme.extended_palette();
|
||||||
let pair = if is_active {
|
let background = match (is_active, status) {
|
||||||
palette.primary.weak
|
(false, button::Status::Hovered) => {
|
||||||
} else {
|
Some(Background::Color(palette.background.weak.color))
|
||||||
match status {
|
|
||||||
button::Status::Hovered => palette.background.weak,
|
|
||||||
_ => palette.background.base,
|
|
||||||
}
|
}
|
||||||
|
_ => None,
|
||||||
};
|
};
|
||||||
button::Style {
|
button::Style {
|
||||||
background: Some(Background::Color(pair.color)),
|
background,
|
||||||
text_color: if is_active {
|
text_color: if is_active {
|
||||||
pair.text
|
palette.primary.weak.text
|
||||||
} else {
|
} else {
|
||||||
palette.background.base.text.scale_alpha(0.72)
|
palette.background.base.text.scale_alpha(0.72)
|
||||||
},
|
},
|
||||||
border: Border {
|
border: Border::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(),
|
|
||||||
},
|
|
||||||
shadow: iced::Shadow::default(),
|
shadow: iced::Shadow::default(),
|
||||||
snap: false,
|
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 {
|
let row_inner: Row<'_, Message> = if tab.is_start {
|
||||||
row![title_btn].spacing(0).align_y(iced::Center)
|
row![title_btn].spacing(0).align_y(iced::Center)
|
||||||
} else {
|
} else {
|
||||||
let close_btn = button(crate::ui::icons::themed_secondary(
|
let close_btn = button(text("×").size(12))
|
||||||
crate::ui::icons::CLOSE,
|
.on_press(Message::TabClose(idx))
|
||||||
10.0,
|
.height(Fill)
|
||||||
))
|
.padding([4, 8])
|
||||||
.on_press(Message::TabClose(idx))
|
.style(button::subtle);
|
||||||
.padding([3, 5])
|
row![title_btn, close_btn]
|
||||||
.style(move |theme: &Theme, status| {
|
.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 palette = theme.extended_palette();
|
||||||
let pair = match status {
|
container::Style {
|
||||||
button::Status::Hovered | button::Status::Pressed => palette.danger.weak,
|
background: Some(Background::Color(if is_active {
|
||||||
_ if is_active => palette.primary.weak,
|
palette.primary.weak.color
|
||||||
_ => palette.background.base,
|
} else {
|
||||||
};
|
palette.background.base.color
|
||||||
button::Style {
|
})),
|
||||||
background: Some(Background::Color(pair.color)),
|
|
||||||
text_color: pair.text,
|
|
||||||
border: Border {
|
border: Border {
|
||||||
radius: 3.0.into(),
|
color: if is_active {
|
||||||
..Default::default()
|
palette.primary.base.color
|
||||||
|
} else {
|
||||||
|
Color::TRANSPARENT
|
||||||
|
},
|
||||||
|
width: if is_active { 1.0 } else { 0.0 },
|
||||||
|
radius: 0.0.into(),
|
||||||
},
|
},
|
||||||
..Default::default()
|
..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 {
|
let tab_element: Element<'_, Message> = if tab.is_start {
|
||||||
tab_container.into()
|
tab_container.into()
|
||||||
} else {
|
} else {
|
||||||
let has_current_path = tab.current_path.is_some();
|
let has_current_path = tab.current_path.is_some();
|
||||||
let has_other_drawings = drag_targets.len() > 1;
|
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(
|
crate::ui::wrap_bar::PosReport::owned(
|
||||||
format!("DOC_TAB:{idx}"),
|
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)
|
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: &'a [crate::videos::VideoEntry],
|
||||||
videos_loading: bool,
|
videos_loading: bool,
|
||||||
video_thumbs: &'a std::collections::HashMap<String, iced::widget::image::Handle>,
|
video_thumbs: &'a std::collections::HashMap<String, iced::widget::image::Handle>,
|
||||||
|
discussions: &'a [crate::discussions::DiscussionEntry],
|
||||||
|
discussions_loading: bool,
|
||||||
recents: &'a [std::path::PathBuf],
|
recents: &'a [std::path::PathBuf],
|
||||||
thumbs: &'a std::collections::HashMap<
|
thumbs: &'a std::collections::HashMap<
|
||||||
std::path::PathBuf,
|
std::path::PathBuf,
|
||||||
|
|
@ -2301,24 +2304,21 @@ pub(super) fn start_page_view<'a>(
|
||||||
.height(Fill);
|
.height(Fill);
|
||||||
|
|
||||||
// Collapse side panels one at a time as width shrinks: Tutorials first,
|
// Collapse side panels one at a time as width shrinks: Tutorials first,
|
||||||
// then Supporters, and Recent Documents last. The previous all-or-nothing
|
// then Discussions, Supporters, and Recent Documents last.
|
||||||
// threshold reserved a videos-sized empty margin on both sides of the
|
|
||||||
// centred Welcome block and hid every panel around 1664 px.
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
enum StartLayout {
|
enum StartLayout {
|
||||||
AllPanels,
|
AllPanels,
|
||||||
WithoutVideos,
|
WithoutVideos,
|
||||||
|
WithoutVideosAndDiscussions,
|
||||||
RecentAndWelcome,
|
RecentAndWelcome,
|
||||||
Compact,
|
Compact,
|
||||||
}
|
}
|
||||||
let recent_w = 280.0f32;
|
let panel_w = 280.0f32;
|
||||||
let videos_w = 300.0f32;
|
|
||||||
let sup_w = 240.0f32;
|
|
||||||
let welcome_wide_min = 360.0f32;
|
let welcome_wide_min = 360.0f32;
|
||||||
let avail = (avail_w - 16.0).max(0.0); // minus the page's l/r padding
|
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 panel_widths = [panel_w; 4];
|
||||||
let mut panel_visible = [true, true, true];
|
let mut panel_visible = [true, true, true, true];
|
||||||
let required_width = |visible: &[bool; 3]| {
|
let required_width = |visible: &[bool; 4]| {
|
||||||
let visible_panels = visible.iter().filter(|&&shown| shown).count();
|
let visible_panels = visible.iter().filter(|&&shown| shown).count();
|
||||||
welcome_wide_min
|
welcome_wide_min
|
||||||
+ panel_widths
|
+ panel_widths
|
||||||
|
|
@ -2330,16 +2330,17 @@ pub(super) fn start_page_view<'a>(
|
||||||
};
|
};
|
||||||
// Re-measure after every collapse. There are no independent breakpoints:
|
// Re-measure after every collapse. There are no independent breakpoints:
|
||||||
// the available width and the panels' preferred widths decide the state.
|
// 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 {
|
if required_width(&panel_visible) <= avail {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
panel_visible[panel] = false;
|
panel_visible[panel] = false;
|
||||||
}
|
}
|
||||||
let start_layout = match panel_visible {
|
let start_layout = match panel_visible {
|
||||||
[true, true, true] => StartLayout::AllPanels,
|
[true, true, true, true] => StartLayout::AllPanels,
|
||||||
[true, false, true] => StartLayout::WithoutVideos,
|
[true, false, true, true] => StartLayout::WithoutVideos,
|
||||||
[true, false, false] => StartLayout::RecentAndWelcome,
|
[true, false, false, true] => StartLayout::WithoutVideosAndDiscussions,
|
||||||
|
[true, false, false, false] => StartLayout::RecentAndWelcome,
|
||||||
_ => StartLayout::Compact,
|
_ => StartLayout::Compact,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2351,7 +2352,8 @@ pub(super) fn start_page_view<'a>(
|
||||||
match start_layout {
|
match start_layout {
|
||||||
StartLayout::AllPanels
|
StartLayout::AllPanels
|
||||||
| StartLayout::WithoutVideos
|
| StartLayout::WithoutVideos
|
||||||
| StartLayout::RecentAndWelcome => iced::Length::Fixed(recent_w),
|
| StartLayout::WithoutVideosAndDiscussions
|
||||||
|
| StartLayout::RecentAndWelcome => iced::Length::Fixed(panel_w),
|
||||||
StartLayout::Compact => iced::Length::Fill,
|
StartLayout::Compact => iced::Length::Fill,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -2435,8 +2437,9 @@ pub(super) fn start_page_view<'a>(
|
||||||
playlist_btn,
|
playlist_btn,
|
||||||
])
|
])
|
||||||
.width(match start_layout {
|
.width(match start_layout {
|
||||||
StartLayout::AllPanels => iced::Length::Fixed(videos_w),
|
StartLayout::AllPanels => iced::Length::Fixed(panel_w),
|
||||||
StartLayout::WithoutVideos
|
StartLayout::WithoutVideos
|
||||||
|
| StartLayout::WithoutVideosAndDiscussions
|
||||||
| StartLayout::RecentAndWelcome
|
| StartLayout::RecentAndWelcome
|
||||||
| StartLayout::Compact => iced::Length::Fill,
|
| StartLayout::Compact => iced::Length::Fill,
|
||||||
})
|
})
|
||||||
|
|
@ -2457,6 +2460,125 @@ pub(super) fn start_page_view<'a>(
|
||||||
.into()
|
.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
|
// Right rail: Patreon supporters, fetched at boot. When the list is empty
|
||||||
// (no token configured / offline) only the "Support on Patreon" button
|
// (no token configured / offline) only the "Support on Patreon" button
|
||||||
// shows, so the rail always invites support.
|
// shows, so the rail always invites support.
|
||||||
|
|
@ -2515,8 +2637,10 @@ pub(super) fn start_page_view<'a>(
|
||||||
support_btn,
|
support_btn,
|
||||||
])
|
])
|
||||||
.width(match start_layout {
|
.width(match start_layout {
|
||||||
StartLayout::AllPanels | StartLayout::WithoutVideos => {
|
StartLayout::AllPanels
|
||||||
iced::Length::Fixed(sup_w)
|
| StartLayout::WithoutVideos
|
||||||
|
| StartLayout::WithoutVideosAndDiscussions => {
|
||||||
|
iced::Length::Fixed(panel_w)
|
||||||
}
|
}
|
||||||
StartLayout::RecentAndWelcome
|
StartLayout::RecentAndWelcome
|
||||||
| StartLayout::Compact => iced::Length::Fill,
|
| StartLayout::Compact => iced::Length::Fill,
|
||||||
|
|
@ -2543,12 +2667,19 @@ pub(super) fn start_page_view<'a>(
|
||||||
recent,
|
recent,
|
||||||
videos_panel,
|
videos_panel,
|
||||||
welcome,
|
welcome,
|
||||||
|
discussions_panel,
|
||||||
supporters,
|
supporters,
|
||||||
]
|
]
|
||||||
.spacing(16)
|
.spacing(16)
|
||||||
.height(Fill)
|
.height(Fill)
|
||||||
.into(),
|
.into(),
|
||||||
StartLayout::WithoutVideos => {
|
StartLayout::WithoutVideos => {
|
||||||
|
iced::widget::row![recent, welcome, discussions_panel, supporters]
|
||||||
|
.spacing(16)
|
||||||
|
.height(Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
StartLayout::WithoutVideosAndDiscussions => {
|
||||||
iced::widget::row![recent, welcome, supporters]
|
iced::widget::row![recent, welcome, supporters]
|
||||||
.spacing(16)
|
.spacing(16)
|
||||||
.height(Fill)
|
.height(Fill)
|
||||||
|
|
@ -2595,6 +2726,7 @@ pub(super) fn start_page_view<'a>(
|
||||||
tab_btn("Recent Files", super::StartSection::Recent).into(),
|
tab_btn("Recent Files", super::StartSection::Recent).into(),
|
||||||
tab_btn("Videos", super::StartSection::Videos).into(),
|
tab_btn("Videos", super::StartSection::Videos).into(),
|
||||||
tab_btn("Welcome", super::StartSection::Welcome).into(),
|
tab_btn("Welcome", super::StartSection::Welcome).into(),
|
||||||
|
tab_btn("Discussions", super::StartSection::Discussions).into(),
|
||||||
tab_btn("Supporters", super::StartSection::Supporters).into(),
|
tab_btn("Supporters", super::StartSection::Supporters).into(),
|
||||||
])
|
])
|
||||||
.spacing_x(6.0)
|
.spacing_x(6.0)
|
||||||
|
|
@ -2611,6 +2743,11 @@ pub(super) fn start_page_view<'a>(
|
||||||
.center_x(Fill)
|
.center_x(Fill)
|
||||||
.into(),
|
.into(),
|
||||||
super::StartSection::Welcome => welcome.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)
|
super::StartSection::Supporters => container(supporters)
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
.height(Fill)
|
.height(Fill)
|
||||||
|
|
|
||||||
240
src/discussions.rs
Normal file
240
src/discussions.rs
Normal file
|
|
@ -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<Vec<DiscussionEntry>, String> {
|
||||||
|
let mut entries: Vec<DiscussionEntry> =
|
||||||
|
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<std::path::PathBuf> {
|
||||||
|
crate::config::config_dir().map(|dir| dir.join("discussions.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn load_cached() -> Vec<DiscussionEntry> {
|
||||||
|
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<Vec<DiscussionEntry>, 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<String, String> {
|
||||||
|
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<DiscussionEntry> {
|
||||||
|
feed.split("<entry>")
|
||||||
|
.skip(1)
|
||||||
|
.filter_map(|tail| {
|
||||||
|
let entry = tail.split_once("</entry>")?.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("<author>")?.1.split_once("</author>")?.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<String> {
|
||||||
|
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::<Vec<_>>().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<u64> {
|
||||||
|
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("</ul>")
|
||||||
|
.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::<String>();
|
||||||
|
if let Ok(number) = digits.parse() {
|
||||||
|
numbers.insert(number);
|
||||||
|
}
|
||||||
|
rest = tail;
|
||||||
|
}
|
||||||
|
numbers
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub async fn fetch_discussions_web() -> Result<Vec<DiscussionEntry>, 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#"
|
||||||
|
<entry>
|
||||||
|
<link type="text/html" rel="alternate" href="https://github.com/HakanSeven12/OpenCADStudio/discussions/2"/>
|
||||||
|
<title>New & recent</title>
|
||||||
|
<updated>2026-07-28T10:00:00+00:00</updated>
|
||||||
|
<author><name>alice</name></author>
|
||||||
|
</entry>
|
||||||
|
<entry>
|
||||||
|
<link type="text/html" rel="alternate" href="https://github.com/HakanSeven12/OpenCADStudio/discussions/1"/>
|
||||||
|
<title>Pinned</title>
|
||||||
|
<updated>2026-07-20T10:00:00+00:00</updated>
|
||||||
|
<author><name>bob</name></author>
|
||||||
|
</entry>
|
||||||
|
"#;
|
||||||
|
let page = r#"
|
||||||
|
<h2 id="pinned-discussions">Pinned Discussions</h2>
|
||||||
|
<ul><a href="/HakanSeven12/OpenCADStudio/discussions/1">Pinned</a></ul>
|
||||||
|
"#;
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ pub mod entities;
|
||||||
pub mod io;
|
pub mod io;
|
||||||
pub mod modules;
|
pub mod modules;
|
||||||
pub mod patreon;
|
pub mod patreon;
|
||||||
|
pub mod discussions;
|
||||||
pub mod videos;
|
pub mod videos;
|
||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
pub mod perf;
|
pub mod perf;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ mod entities;
|
||||||
mod io;
|
mod io;
|
||||||
mod modules;
|
mod modules;
|
||||||
mod patreon;
|
mod patreon;
|
||||||
|
mod discussions;
|
||||||
mod videos;
|
mod videos;
|
||||||
mod plugin;
|
mod plugin;
|
||||||
mod perf;
|
mod perf;
|
||||||
|
|
|
||||||
363
src/ui/icons.rs
363
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
|
//! Dropdown carets and the undo/redo controls used to be drawn as Unicode
|
||||||
//! glyphs (`▾`, `▲`, `↶`, `↷`). Those depend on the active text font carrying
|
//! 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
|
//! build bundles only Fira Sans, which lacks them, so they rendered as empty
|
||||||
//! boxes. Drawing them from SVG instead makes the chrome font-independent.
|
//! 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::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_DOWN: &[u8] = include_bytes!("../../assets/icons/ui/tri_down.svg");
|
||||||
const TRI_UP: &[u8] = include_bytes!("../../assets/icons/ui/tri_up.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 PRINT: &[u8] = include_bytes!("../../assets/icons/ui/print.svg");
|
||||||
pub const HEART: &[u8] = include_bytes!("../../assets/icons/ui/heart.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 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");
|
pub const ARROW_LONG_RIGHT: &[u8] = include_bytes!("../../assets/icons/ui/arrow_long_right.svg");
|
||||||
|
|
||||||
// ── Status-bar toggle icons (issue #216) ──────────────────────────────────
|
// ── 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_SELCYCLE: &[u8] = include_bytes!("../../assets/icons/status/selcycle.svg");
|
||||||
pub const ST_CLEANSCREEN: &[u8] = include_bytes!("../../assets/icons/status/cleanscreen.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<FxHashMap<SemanticCacheKey, svg::Handle>> =
|
||||||
|
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<M> Widget<M, Theme, Renderer> for SemanticIcon {
|
||||||
|
fn size(&self) -> Size<Length> {
|
||||||
|
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<u8> {
|
||||||
|
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.
|
/// 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> {
|
pub fn themed<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
|
||||||
svg(svg::Handle::from_memory(bytes))
|
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()
|
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)
|
/// SVG bytes for an OSNAP mode's marker symbol, for the snap menu. (#138)
|
||||||
pub fn osnap(snap: crate::snap::SnapType) -> &'static [u8] {
|
pub fn osnap(snap: crate::snap::SnapType) -> &'static [u8] {
|
||||||
use crate::snap::SnapType as S;
|
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)
|
themed_disabled(REDO, size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn semantic_svg_uses_multiple_theme_roles() {
|
||||||
|
let source = br##"<svg>
|
||||||
|
<path stroke="#e0e0e0"/>
|
||||||
|
<path fill="#4cc9f0"/>
|
||||||
|
<path fill="#f0c040"/>
|
||||||
|
<path fill="#e05050"/>
|
||||||
|
<path fill="#4ccf6f"/>
|
||||||
|
<path fill="#123456"/>
|
||||||
|
</svg>"##;
|
||||||
|
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##"<path stroke="white"/>"##, &theme);
|
||||||
|
|
||||||
|
assert!(contains(&themed, &colors.text));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains(source: &[u8], needle: &[u8]) -> bool {
|
||||||
|
source
|
||||||
|
.windows(needle.len())
|
||||||
|
.any(|window| window == needle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -869,7 +869,7 @@ impl Ribbon {
|
||||||
// on it flips that state instead of bubbling up to the row's
|
// on it flips that state instead of bubbling up to the row's
|
||||||
// make-active handler (#133).
|
// make-active handler (#133).
|
||||||
let icon_btn = |bytes: &'static [u8], msg: Message| -> Element<'_, Message> {
|
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)
|
.on_press(msg)
|
||||||
.style(popup_row_style)
|
.style(popup_row_style)
|
||||||
.padding([2, 4])
|
.padding([2, 4])
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ pub(super) fn flush_small_col<'a>(
|
||||||
pub(super) fn make_icon(icon: IconKind, size: f32) -> Element<'static, Message> {
|
pub(super) fn make_icon(icon: IconKind, size: f32) -> Element<'static, Message> {
|
||||||
match icon {
|
match icon {
|
||||||
IconKind::Glyph(s) => text(s).size(size * 0.7).into(),
|
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))
|
&& !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> {
|
pub(super) fn make_icon_dim(icon: IconKind, size: f32, dim: bool) -> Element<'static, Message> {
|
||||||
if !dim {
|
if !dim {
|
||||||
return make_icon(icon, size);
|
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)),
|
color: Some(theme.extended_palette().background.base.text.scale_alpha(0.42)),
|
||||||
})
|
})
|
||||||
.into(),
|
.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 ll = info.map(|l| l.locked).unwrap_or(false);
|
||||||
let is_open = open_dd.as_deref() == Some(LAYER_COMBO_ID);
|
let is_open = open_dd.as_deref() == Some(LAYER_COMBO_ID);
|
||||||
|
|
||||||
let vis_icon = icons::raw(icons::layer_visible(lv), 14.0);
|
let vis_icon = icons::semantic(icons::layer_visible(lv), 14.0);
|
||||||
let freeze_icon = icons::raw(icons::layer_freeze(lf), 14.0);
|
let freeze_icon = icons::semantic(icons::layer_freeze(lf), 14.0);
|
||||||
let lock_icon = icons::raw(icons::layer_lock(ll), 14.0);
|
let lock_icon = icons::semantic(icons::layer_lock(ll), 14.0);
|
||||||
let swatch = container(text(""))
|
let swatch = container(text(""))
|
||||||
.style(move |theme: &Theme| container::Style {
|
.style(move |theme: &Theme| container::Style {
|
||||||
background: Some(Background::Color(lc)),
|
background: Some(Background::Color(lc)),
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ const EDGE_MARGIN: f32 = 8.0;
|
||||||
fn icon_el(icon: IconKind) -> Element<'static, Message> {
|
fn icon_el(icon: IconKind) -> Element<'static, Message> {
|
||||||
match icon {
|
match icon {
|
||||||
IconKind::Glyph(s) => text(s).size(ICON_SIZE * 0.85).into(),
|
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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -653,11 +653,7 @@ fn layer_row<'a>(
|
||||||
name_col_w: f32,
|
name_col_w: f32,
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
let svg_btn = |bytes: &'static [u8], on_press: Message| -> Element<'a, Message> {
|
let svg_btn = |bytes: &'static [u8], on_press: Message| -> Element<'a, Message> {
|
||||||
button(
|
button(crate::ui::icons::semantic(bytes, ICON_SZ))
|
||||||
iced::widget::svg(iced::widget::svg::Handle::from_memory(bytes))
|
|
||||||
.width(ICON_SZ)
|
|
||||||
.height(ICON_SZ),
|
|
||||||
)
|
|
||||||
.on_press(on_press)
|
.on_press(on_press)
|
||||||
.style(|theme: &Theme, status| button::Style {
|
.style(|theme: &Theme, status| button::Style {
|
||||||
background: matches!(status, button::Status::Hovered).then_some(
|
background: matches!(status, button::Status::Hovered).then_some(
|
||||||
|
|
|
||||||
42
web/discussions.json
Normal file
42
web/discussions.json
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
Loading…
Reference in a new issue