forked from 1b-agent/opencadstudio-upstream
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:
1 parent
4bf14baea4
commit
28d8246c68
16 files changed
+960
-98
No files matched your search
+353
-10
@@ -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<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.
|
||||
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##"<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
|
||||
// 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])
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user