Merge pull request #1549 from MarcelP102/codex/graphic-attributes-cleanup

[UX] Introducing graphic attributes panel

# Conflicts:
#	locales/ar-SA/opencadstudio.ftl
#	locales/bg-BG/opencadstudio.ftl
#	locales/cs-CZ/opencadstudio.ftl
#	locales/de-DE/opencadstudio.ftl
#	locales/el-GR/opencadstudio.ftl
#	locales/en-US/opencadstudio.ftl
#	locales/es-ES/opencadstudio.ftl
#	locales/fi-FI/opencadstudio.ftl
#	locales/fr-FR/opencadstudio.ftl
#	locales/hi-IN/opencadstudio.ftl
#	locales/hu-HU/opencadstudio.ftl
#	locales/it-IT/opencadstudio.ftl
#	locales/ja-JP/opencadstudio.ftl
#	locales/ko-KR/opencadstudio.ftl
#	locales/lo-LA/opencadstudio.ftl
#	locales/nl-NL/opencadstudio.ftl
#	locales/pl-PL/opencadstudio.ftl
#	locales/pt-BR/opencadstudio.ftl
#	locales/ru-RU/opencadstudio.ftl
#	locales/tr-TR/opencadstudio.ftl
#	locales/zh-CN/opencadstudio.ftl
#	locales/zh-TW/opencadstudio.ftl
#	src/app/mod.rs
#	src/app/update/dialog.rs
#	src/app/view/mod.rs
#	src/locale_catalog.rs
#	src/ui/dock.rs
This commit is contained in:
Hakan Seven committed 2026-10-12 00:49:27 +03:00
commit 1333efcad2
68 files changed
+6178 -173

No files matched your search

+15 -13
View File
@@ -230,18 +230,7 @@ fn color_selector_with_indicator<'a>(
}
let popup = container(color_list(extras, on_select, on_more))
.style(|theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.background.weak.color)),
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 2.0.into(),
},
..Default::default()
}
})
.style(popup_panel_style)
.padding(2);
// `DropDown` keeps the popup outside the surrounding form layout and
@@ -255,7 +244,20 @@ fn color_selector_with_indicator<'a>(
.into()
}
fn list_row_style(theme: &Theme, status: button::Status) -> button::Style {
pub(crate) fn popup_panel_style(theme: &Theme) -> container::Style {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.background.weak.color)),
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 2.0.into(),
},
..Default::default()
}
}
pub(crate) fn list_row_style(theme: &Theme, status: button::Status) -> button::Style {
let palette = theme.palette();
let hovered = matches!(status, button::Status::Hovered);
let text_color = if hovered {
+45 -2
View File
@@ -104,6 +104,7 @@ pub const DOCK_HOVER_DELAY: std::time::Duration = std::time::Duration::from_mill
#[serde(rename_all = "snake_case")]
pub enum PanelId {
Properties,
GraphicAttributes,
BlockPalette,
ExternalReferences,
/// Outline of the drawing's origin planes, open sketch and solid bodies.
@@ -122,8 +123,9 @@ pub enum PanelId {
impl PanelId {
/// Every panel, in the order the pallet menu lists them.
pub const ALL: [PanelId; 9] = [
pub const ALL: [PanelId; 10] = [
PanelId::Properties,
PanelId::GraphicAttributes,
PanelId::BlockPalette,
PanelId::ExternalReferences,
PanelId::Browser,
@@ -138,6 +140,7 @@ impl PanelId {
pub fn title(self) -> &'static str {
match self {
PanelId::Properties => "Properties",
PanelId::GraphicAttributes => "Graphic Attributes",
PanelId::BlockPalette => "Block Palette",
PanelId::ExternalReferences => "External References",
PanelId::Browser => "Browser",
@@ -153,6 +156,7 @@ impl PanelId {
pub fn icon(self) -> &'static [u8] {
match self {
PanelId::Properties => include_bytes!("../../assets/icons/properties.svg"),
PanelId::GraphicAttributes => include_bytes!("../../assets/icons/graphic_attributes.svg"),
PanelId::BlockPalette => include_bytes!("../../assets/icons/blocks/insert.svg"),
PanelId::ExternalReferences => crate::ui::icons::FOLDER_OPEN,
PanelId::Browser => include_bytes!("../../assets/icons/content_browser.svg"),
@@ -168,6 +172,7 @@ impl PanelId {
fn default_width(self) -> f32 {
match self {
PanelId::Properties => 250.0,
PanelId::GraphicAttributes => 250.0,
PanelId::BlockPalette => 260.0,
PanelId::ExternalReferences => 460.0,
PanelId::Browser => 230.0,
@@ -518,6 +523,23 @@ impl DockState {
self.floating.iter_mut().for_each(FloatPanel::heal);
}
/// Stack `id` under Properties in its group (or dock it at the end of
/// the left edge) unless it already has a place, which is then kept.
pub fn dock_below_properties(&mut self, id: PanelId) {
if self.is_placed(id) {
return;
}
match self.location(PanelId::Properties) {
Some((side, group)) => {
let index = self.groups(side)[group].panels.len();
self.join_group(id, side, group, index);
}
None => {
self.dock(id, DockSide::Left, usize::MAX);
}
}
}
/// Where (if anywhere) a panel is docked: its side and group index.
pub fn location(&self, id: PanelId) -> Option<(DockSide, usize)> {
for side in [DockSide::Left, DockSide::Right] {
@@ -1054,8 +1076,25 @@ pub struct Chrome {
/// and close. Pressing the row starts re-docking the panel. A floating panel
/// gets nothing here: its frame draws a vertical title bar beside it.
pub fn title_bar<'a>(id: PanelId, title: String, chrome: Chrome) -> Element<'a, Message> {
title_bar_with(id, title, chrome, None)
}
/// [`title_bar`] with a pallet's own control (a menu button) before the pin
/// and close. A floating pallet has no title bar, so the control then sits
/// alone at the top right.
pub fn title_bar_with<'a>(
id: PanelId,
title: String,
chrome: Chrome,
extra: Option<Element<'a, Message>>,
) -> Element<'a, Message> {
if chrome.floating {
return Space::new().width(0).height(0).into();
return match extra {
Some(extra) => row![Space::new().width(Length::Fill), extra]
.padding([3, 6])
.into(),
None => Space::new().width(0).height(0).into(),
};
}
// The pin and close only show while the pointer is over the bar; the
// fixed height keeps the bar from jumping when they appear.
@@ -1063,6 +1102,9 @@ pub fn title_bar<'a>(id: PanelId, title: String, chrome: Chrome) -> Element<'a,
.spacing(3)
.height(Length::Fixed(TITLE_BUTTON_H))
.align_y(iced::Center);
if let Some(extra) = extra {
bar = bar.push(extra);
}
if chrome.title_hovered {
bar = bar
.push(pin_button(
@@ -1230,6 +1272,7 @@ mod tests {
let mut state = DockState::default();
state.ensure_settings();
assert_eq!(state.width(PanelId::Properties, 1600.0), 250.0);
assert_eq!(state.width(PanelId::GraphicAttributes, 1600.0), 250.0);
assert_eq!(state.width(PanelId::BlockPalette, 1600.0), 260.0);
assert!(!state.auto_collapse(PanelId::Properties));
}
+151 -90
View File
@@ -55,6 +55,9 @@ pub fn linetype_display_name(name: &str) -> String {
impl fmt::Display for LinetypeItem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.name.is_empty() && !self.art.is_empty() {
return f.write_str(&self.art);
}
let name = linetype_display_name(&self.name);
if self.art.is_empty() {
write!(f, "{name}")
@@ -95,6 +98,7 @@ impl fmt::Display for SelectionGroup {
#[derive(Clone)]
struct HatchPatternPreview {
pattern: crate::scene::model::hatch_model::HatchPattern,
padding: f32,
}
impl canvas::Program<Message> for HatchPatternPreview {
@@ -112,7 +116,7 @@ impl canvas::Program<Message> for HatchPatternPreview {
let mut frame = canvas::Frame::new(renderer, bounds.size());
let palette = theme.palette();
let pad = 4.0;
let pad = self.padding;
let sample = canvas::Path::rectangle(
Point::new(pad, pad),
Size::new(
@@ -201,6 +205,31 @@ fn hatch_preview_scale(pattern: &crate::scene::model::hatch_model::HatchPattern)
}
}
fn hatch_pattern_preview(
pattern: crate::scene::model::hatch_model::HatchPattern,
height: f32,
) -> Element<'static, Message> {
canvas(HatchPatternPreview {
pattern,
padding: 4.0,
})
.width(Length::Fill)
.height(height)
.into()
}
pub(crate) fn compact_hatch_pattern_preview(
pattern: crate::scene::model::hatch_model::HatchPattern,
) -> Element<'static, Message> {
canvas(HatchPatternPreview {
pattern,
padding: 0.5,
})
.width(Length::Fill)
.height(ROW_H)
.into()
}
fn hatch_pattern_matches(
entry: &crate::scene::model::hatch_patterns::PatternEntry,
search: &str,
@@ -223,6 +252,113 @@ pub(crate) fn filtered_hatch_patterns(
.collect()
}
/// Messages of a hatch pattern picker; the Properties panel and the Graphic
/// Attributes palette each route them to their own state.
pub(crate) struct PatternPickerMessages {
pub search_id: &'static str,
pub on_search: fn(String) -> Message,
pub on_confirm: Message,
pub on_focus: fn(usize) -> Message,
pub on_changed: fn(String) -> Message,
}
pub(crate) fn hatch_pattern_picker_content<'a>(
search_value: &'a str,
focus: usize,
current: &'a str,
messages: PatternPickerMessages,
) -> Element<'a, Message> {
let PatternPickerMessages {
search_id,
on_search,
on_confirm,
on_focus,
on_changed,
} = messages;
let search = text_input(t!("Search patterns…").as_ref(), search_value)
.id(iced::widget::Id::new(search_id))
.on_input(on_search)
.on_submit(on_confirm)
.size(FONT_SZ)
.padding([5, 7])
.width(Length::Fill);
let mut grid = column![].spacing(6);
let visible = filtered_hatch_patterns(search_value);
for (row_index, pair) in visible.chunks(2).enumerate() {
let mut cards = row![].spacing(6);
for (column_index, entry) in pair.iter().enumerate() {
let index = row_index * 2 + column_index;
let selected = current.eq_ignore_ascii_case(&entry.name);
let focused = focus == index;
let name = entry.name.clone();
let preview = hatch_pattern_preview(entry.gpu.clone(), PATTERN_PREVIEW_H);
let card = button(
column![
preview,
container(text(crate::ui::text_util::elide(&entry.name, 20)).size(FONT_SZ))
.width(Length::Fill)
.align_x(iced::Center),
]
.spacing(3),
)
.on_press(on_changed(name))
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let hovered = matches!(status, button::Status::Hovered | button::Status::Pressed);
let pair = if selected {
palette.primary.weak
} else if hovered || focused {
palette.background.strong
} else {
palette.background.weak
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
color: if selected || focused {
palette.primary.base.color
} else {
palette.background.neutral.color
},
width: if selected || focused { 2.0 } else { 1.0 },
radius: 4.0.into(),
},
..Default::default()
}
})
.padding(5)
.width(PATTERN_CARD_W);
cards = cards.push(mouse_area(card).on_enter(on_focus(index)));
}
grid = grid.push(cards);
}
let results: Element<'_, Message> = if visible.is_empty() {
container(
text(t!("No matching patterns"))
.size(FONT_SZ)
.style(hint_text_style),
)
.padding(12)
.width(Length::Fill)
.center_x(Length::Fill)
.into()
} else {
scrollable(grid)
.height(Length::Fill)
.width(Length::Fill)
.into()
};
container(column![search, results].spacing(7))
.style(container::bordered_box)
.padding(8)
.width(PATTERN_PICKER_W)
.height(Length::Fixed(PATTERN_PICKER_H))
.into()
}
/// All standard CAD lineweight options for the combobox.
pub fn lw_options() -> Vec<LwItem> {
[
@@ -1504,99 +1640,24 @@ impl PropertiesPanel {
return prop_row_widget(label, head.into());
}
let search = text_input(t!("Search patterns…").as_ref(), &self.hatch_pattern_search)
.id(iced::widget::Id::new("hatch-pattern-search"))
.on_input(Message::PropHatchPatternSearchChanged)
.on_submit(Message::PropHatchPatternConfirm)
.size(FONT_SZ)
.padding([5, 7])
.width(Length::Fill);
let mut grid = column![].spacing(6);
let visible = filtered_hatch_patterns(&self.hatch_pattern_search);
for (row_index, pair) in visible.chunks(2).enumerate() {
let mut cards = row![].spacing(6);
for (column_index, entry) in pair.iter().enumerate() {
let index = row_index * 2 + column_index;
let selected = current.eq_ignore_ascii_case(&entry.name);
let focused = self.hatch_pattern_focus == index;
let name = entry.name.clone();
let preview = canvas(HatchPatternPreview {
pattern: entry.gpu.clone(),
})
.width(Length::Fill)
.height(PATTERN_PREVIEW_H);
let card = button(
column![
preview,
container(text(crate::ui::text_util::elide(&entry.name, 20)).size(FONT_SZ))
.width(Length::Fill)
.align_x(iced::Center),
]
.spacing(3),
)
.on_press(Message::PropHatchPatternChanged(name))
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let hovered =
matches!(status, button::Status::Hovered | button::Status::Pressed);
let pair = if selected {
palette.primary.weak
} else if hovered || focused {
palette.background.strong
} else {
palette.background.weak
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
color: if selected || focused {
palette.primary.base.color
} else {
palette.background.neutral.color
},
width: if selected || focused { 2.0 } else { 1.0 },
radius: 4.0.into(),
},
..Default::default()
}
})
.padding(5)
.width(PATTERN_CARD_W);
cards =
cards.push(mouse_area(card).on_enter(Message::PropHatchPatternFocus(index)));
}
grid = grid.push(cards);
}
let results: Element<'_, Message> = if visible.is_empty() {
container(
text(t!("No matching patterns"))
.size(FONT_SZ)
.style(hint_text_style),
)
.padding(12)
.width(Length::Fill)
.center_x(Length::Fill)
.into()
} else {
scrollable(grid)
.height(Length::Fill)
.width(Length::Fill)
.into()
};
let popup = container(column![search, results].spacing(7))
.style(container::bordered_box)
.padding(8)
.width(PATTERN_PICKER_W)
.height(Length::Fixed(PATTERN_PICKER_H));
let popup = hatch_pattern_picker_content(
&self.hatch_pattern_search,
self.hatch_pattern_focus,
current,
PatternPickerMessages {
search_id: "hatch-pattern-search",
on_search: Message::PropHatchPatternSearchChanged,
on_confirm: Message::PropHatchPatternConfirm,
on_focus: Message::PropHatchPatternFocus,
on_changed: Message::PropHatchPatternChanged,
},
);
prop_row_widget(
label,
crate::ui::color_select::drop_down_below(
head.into(),
popup.into(),
popup,
Some(Length::Fixed(PATTERN_PICKER_W)),
Length::Fixed(PATTERN_PICKER_H),
Message::PropHatchPatternPickerToggle(current.to_string()),
@@ -2158,7 +2219,7 @@ fn text_input_style(theme: &Theme, status: text_input::Status) -> text_input::St
}
}
fn combo_input_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
pub(crate) fn combo_input_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
text_input_style(theme, status)
}
+7
View File
@@ -62,6 +62,8 @@ pub struct Ribbon {
pub show_count_palette: bool,
/// Sheet Set Manager palette shown (SSMSTATE).
pub show_sheet_set: bool,
/// Graphic Attributes panel visibility — drives its ribbon button highlight.
pub show_graphic_attributes: bool,
/// File tabs (FILETAB) visibility — drives the File Tabs button highlight.
pub show_file_tabs: bool,
/// Layout tabs (LAYOUTTAB) visibility — drives the Layout Tabs button highlight.
@@ -181,6 +183,7 @@ impl Ribbon {
show_properties: true,
show_count_palette: false,
show_sheet_set: false,
show_graphic_attributes: false,
show_file_tabs: true,
show_layout_tabs: true,
open_dropdown: None,
@@ -346,6 +349,9 @@ impl Ribbon {
pub fn set_sheet_set(&mut self, on: bool) {
self.show_sheet_set = on;
}
pub fn set_graphic_attributes(&mut self, on: bool) {
self.show_graphic_attributes = on;
}
pub fn set_file_tabs(&mut self, on: bool) {
self.show_file_tabs = on;
}
@@ -364,6 +370,7 @@ impl Ribbon {
show_ucs_icon: self.show_ucs_icon,
show_properties: self.show_properties,
show_count_palette: self.show_count_palette,
show_graphic_attributes: self.show_graphic_attributes,
show_block_palette,
show_sheet_set: self.show_sheet_set,
show_file_tabs: self.show_file_tabs,
+2
View File
@@ -38,6 +38,7 @@ pub(super) struct ToggleState {
pub show_ucs_icon: bool,
pub show_properties: bool,
pub show_count_palette: bool,
pub show_graphic_attributes: bool,
pub show_block_palette: bool,
pub show_sheet_set: bool,
pub show_file_tabs: bool,
@@ -370,6 +371,7 @@ pub(super) fn is_active_tool(id: &str, active_tool: &Option<String>, state: &Tog
"UCSICON" => state.show_ucs_icon,
"PROPERTIES" => state.show_properties,
"COUNTLIST" => state.show_count_palette,
"GRAPHICATTRIBUTES" => state.show_graphic_attributes,
"BLOCKPALETTE" => state.show_block_palette,
"SHEETSET" => state.show_sheet_set,
"FILETAB" => state.show_file_tabs,
+498
View File
@@ -0,0 +1,498 @@
//! Gradient flyout of the Graphic Attributes palette.
use crate::app::Message;
use crate::scene::model::hatch_model::GradientKind;
use crate::ui::window::graphic_attributes::GraphicAttributesMsg;
use codec::types::Color as AcadColor;
use iced::widget::{
button, checkbox, column, container, image, mouse_area, row, slider, text, text_input, tooltip,
Space,
};
use iced::{Background, Border, Color, Element, Fill, Length, Theme};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GradientColorMode {
One,
Two,
}
#[derive(Debug, Clone)]
pub struct GradientEditorState {
pub handles: Vec<codec::Handle>,
pub color_mode: GradientColorMode,
pub color_1: AcadColor,
pub color_2: AcadColor,
pub shade_tint: f32,
pub kind: GradientKind,
pub inverted: bool,
pub angle: String,
pub centered: bool,
/// Large preview, rebuilt only when a setting it depends on changes.
preview: std::cell::RefCell<Option<(PreviewKey, Option<iced::widget::image::Handle>)>>,
}
type PreviewKey = (GradientColorMode, AcadColor, AcadColor, u32, GradientKind, bool, String, bool);
#[derive(Debug, Clone)]
pub enum GradientMsg {
Open,
Cancel,
Apply,
ColorMode(GradientColorMode),
/// Stop colour 1 or 2.
Color(u8, AcadColor),
ShadeTint(f32),
Kind(GradientKind),
InvertToggled,
Angle(String),
AngleReset,
Centered(bool),
}
fn msg(message: GradientMsg) -> Message {
Message::GraphicAttributes(GraphicAttributesMsg::Gradient(message))
}
impl GradientEditorState {
pub fn from_hatch(
handles: Vec<codec::Handle>,
hatch: &codec::entities::Hatch,
) -> Self {
let color = |index: usize| {
hatch
.gradient_color
.colors
.get(index)
.map(|entry| entry.color)
.unwrap_or(AcadColor::Index(7))
};
let (kind, inverted) = GradientKind::from_name(&hatch.gradient_color.name);
Self {
handles,
color_mode: if hatch.gradient_color.is_single_color {
GradientColorMode::One
} else {
GradientColorMode::Two
},
color_1: color(0),
color_2: color(1),
shade_tint: hatch.gradient_color.color_tint as f32,
kind,
inverted,
angle: format!("{:.1}°", hatch.gradient_color.angle.to_degrees()),
centered: hatch.gradient_color.shift.abs() < 1e-9,
preview: Default::default(),
}
}
pub fn update(&mut self, message: GradientMsg) {
match message {
GradientMsg::ColorMode(mode) => self.color_mode = mode,
GradientMsg::Color(1, color) => self.color_1 = color,
GradientMsg::Color(_, color) => self.color_2 = color,
GradientMsg::ShadeTint(value) => self.shade_tint = value.clamp(0.0, 1.0),
GradientMsg::Kind(kind) => {
self.kind = kind;
if kind == GradientKind::Linear {
self.inverted = false;
}
}
GradientMsg::InvertToggled => {
if self.kind != GradientKind::Linear {
self.inverted ^= true;
}
}
GradientMsg::Angle(value) => self.angle = value,
GradientMsg::AngleReset => self.angle = "0.0°".into(),
GradientMsg::Centered(value) => self.centered = value,
GradientMsg::Open | GradientMsg::Cancel | GradientMsg::Apply => {}
}
}
pub fn angle_radians(&self) -> f64 {
self.angle
.trim()
.trim_end_matches('°')
.replace(',', ".")
.parse::<f64>()
.unwrap_or(0.0)
.rem_euclid(360.0)
.to_radians()
}
}
fn rgb(color: &AcadColor) -> Color {
let (r, g, b) = match color {
AcadColor::Rgb { r, g, b } => (*r, *g, *b),
AcadColor::Index(i) => {
codec::types::aci_table::aci_to_rgb(*i).unwrap_or((128, 128, 128))
}
_ => (255, 255, 255),
};
Color::from_rgb8(r, g, b)
}
fn rgb_array(color: &AcadColor) -> [f32; 4] {
let c = rgb(color);
[c.r, c.g, c.b, 1.0]
}
fn mix_rgba(a: Color, b: Color, t: f32) -> [u8; 4] {
let channel = |a: f32, b: f32| ((a + (b - a) * t) * 255.0).round() as u8;
[channel(a.r, b.r), channel(a.g, b.g), channel(a.b, b.b), 255]
}
fn preview_sized(
state: &GradientEditorState,
width: u32,
height: u32,
) -> Option<iced::widget::image::Handle> {
let c1 = rgb(&state.color_1);
let c2 = if state.color_mode == GradientColorMode::One {
let c = crate::scene::gradient_tint_color(rgb_array(&state.color_1), state.shade_tint);
Color::from_rgb(c[0], c[1], c[2])
} else {
rgb(&state.color_2)
};
let angle = state.angle_radians();
let frame = kernel::geom2d::gradient_frame(
&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
angle,
if state.centered { 0.0 } else { 1.0 },
kernel::geom2d::Tolerance::default(),
)?;
let (sin, cos) = angle.sin_cos();
let radial = state.kind.radial();
let mut pixels = Vec::with_capacity((width * height * 4) as usize);
for y in 0..height {
let py = y as f64 / (height - 1) as f64;
for x in 0..width {
let px = x as f64 / (width - 1) as f64;
let mut t = if radial {
((px - frame.center[0]).hypot(py - frame.center[1]) / frame.radius).clamp(0.0, 1.0)
as f32
} else {
(((px * cos + py * sin) - frame.projection_min) / frame.projection_span)
.clamp(0.0, 1.0) as f32
};
if state.kind == GradientKind::Cylinder {
t = 1.0 - (2.0 * t - 1.0).abs();
} else if state.kind == GradientKind::Curved {
t *= t;
} else if state.kind == GradientKind::Hemispherical {
t = t.sqrt();
}
if state.inverted {
t = 1.0 - t;
}
pixels.extend_from_slice(&if radial {
mix_rgba(c2, c1, t)
} else {
mix_rgba(c1, c2, t)
});
}
}
Some(iced::widget::image::Handle::from_rgba(width, height, pixels))
}
fn preview(state: &GradientEditorState) -> Element<'static, Message> {
let key: PreviewKey = (
state.color_mode,
state.color_1,
state.color_2,
state.shade_tint.to_bits(),
state.kind,
state.inverted,
state.angle.clone(),
state.centered,
);
let mut cache = state.preview.borrow_mut();
if cache.as_ref().is_none_or(|(cached, _)| *cached != key) {
*cache = Some((key, preview_sized(state, 370, 80)));
}
preview_image(cache.as_ref().and_then(|(_, handle)| handle.clone()))
}
pub(crate) fn compact_preview(
hatch: &codec::entities::Hatch,
) -> Option<iced::widget::image::Handle> {
preview_sized(&GradientEditorState::from_hatch(Vec::new(), hatch), 256, 26)
}
pub(crate) fn preview_image(handle: Option<iced::widget::image::Handle>) -> Element<'static, Message> {
match handle {
Some(handle) => image(handle)
.width(Fill)
.height(Fill)
.content_fit(iced::ContentFit::Fill)
.into(),
None => Space::new().width(Fill).height(Fill).into(),
}
}
fn icon(kind: GradientKind, inverted: bool) -> &'static [u8] {
match (kind, inverted) {
(GradientKind::Linear, _) => {
include_bytes!("../../../assets/icons/gradient/gradient_linear.svg")
}
(GradientKind::Cylinder, false) => {
include_bytes!("../../../assets/icons/gradient/gradient_cylindrical.svg")
}
(GradientKind::Cylinder, true) => {
include_bytes!("../../../assets/icons/gradient/gradient_cylindrical_inverted.svg")
}
(GradientKind::Spherical, false) => {
include_bytes!("../../../assets/icons/gradient/gradient_spherical.svg")
}
(GradientKind::Spherical, true) => {
include_bytes!("../../../assets/icons/gradient/gradient_spherical_inverted.svg")
}
(GradientKind::Hemispherical, false) => {
include_bytes!("../../../assets/icons/gradient/gradient_hemispherical.svg")
}
(GradientKind::Hemispherical, true) => {
include_bytes!("../../../assets/icons/gradient/gradient_hemispherical_inverted.svg")
}
(GradientKind::Curved, false) => {
include_bytes!("../../../assets/icons/gradient/gradient_curved.svg")
}
(GradientKind::Curved, true) => {
include_bytes!("../../../assets/icons/gradient/gradient_curved_inverted.svg")
}
}
}
pub fn view(state: &GradientEditorState, dimmed: bool) -> Element<'_, Message> {
let segment = |label, mode| {
let selected = state.color_mode == mode;
button(text(label).size(11))
.on_press(msg(GradientMsg::ColorMode(mode)))
.width(Fill)
.style(move |theme: &Theme, status| {
if selected {
button::primary(theme, status)
} else {
button::secondary(theme, status)
}
})
};
let color_field = |label: std::borrow::Cow<'static, str>, index, color: AcadColor| {
let (background, _) = crate::ui::properties::acad_color_display(color);
let value = crate::ui::color_select::color_display_name(color);
let picker = button(
row![
crate::ui::color_select::swatch(background),
text(value).size(10),
Space::new().width(Fill),
]
.spacing(5)
.align_y(iced::Center),
)
.on_press(Message::OpenColorWindow(
crate::app::ColorPickTarget::Gradient(index),
color,
))
.style(|theme: &Theme, status| {
let palette = theme.palette();
button::Style {
background: Some(Background::Color(
if matches!(status, button::Status::Hovered) {
palette.background.weak.color
} else {
palette.background.base.color
},
)),
text_color: palette.background.base.text,
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 2.0.into(),
},
..Default::default()
}
})
.height(crate::ui::ROW_H)
.padding([3, 6])
.width(Fill);
row![text(label).size(11).width(43), picker]
.spacing(4)
.align_y(iced::Center)
.width(Fill)
};
let kinds = [
(GradientKind::Linear, crate::t!("Linear")),
(GradientKind::Cylinder, crate::t!("Cyl.")),
(GradientKind::Spherical, crate::t!("Spher.")),
(GradientKind::Hemispherical, crate::t!("Hemi.")),
(GradientKind::Curved, crate::t!("Curved")),
];
let mut type_row = row![].spacing(3);
for (kind, label) in kinds {
let selected = state.kind == kind;
let b = button(
column![
crate::ui::icons::semantic(
icon(kind, state.inverted && kind != GradientKind::Linear),
25.0
),
text(label).size(9)
]
.align_x(iced::Center),
)
.on_press(msg(GradientMsg::Kind(kind)))
.width(48)
.height(48)
.style(move |theme: &Theme, status| {
let mut s = button::subtle(theme, status);
if selected {
s.border.color = theme.palette().primary.base.color;
s.border.width = 1.0;
}
s
});
type_row = type_row.push(tooltip(
b,
text(kind.choice_label(false)).size(10),
tooltip::Position::Bottom,
));
}
let invert_selected = state.inverted && state.kind != GradientKind::Linear;
let invert = button(column![text("⇄").size(18), text(crate::t!("Invert")).size(9)].align_x(iced::Center))
.on_press_maybe(
(state.kind != GradientKind::Linear).then(|| msg(GradientMsg::InvertToggled)),
)
.width(42)
.height(48)
.style(move |theme: &Theme, status| {
let mut style = button::secondary(theme, status);
if invert_selected {
style.border.color = theme.palette().primary.base.color;
style.border.width = 1.0;
}
style
});
let separator =
container(Space::new().width(1).height(36)).style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.neutral.color)),
..Default::default()
});
type_row = type_row.push(separator).push(tooltip(
invert,
text(crate::t!("Invert gradient")).size(10),
tooltip::Position::Bottom,
));
let colors: Element<'_, Message> = if state.color_mode == GradientColorMode::Two {
row![
color_field(crate::t!("Color 1"), 1, state.color_1),
color_field(crate::t!("Color 2"), 2, state.color_2)
]
.spacing(6)
.into()
} else {
row![
color_field(crate::t!("Color 1"), 1, state.color_1),
row![
text(crate::t!("Tone")).size(11).width(43),
slider(0..=100, (state.shade_tint * 100.0) as i32, |v| {
msg(GradientMsg::ShadeTint(v as f32 / 100.0))
})
.width(Fill)
]
.spacing(4)
.align_y(iced::Center)
.height(crate::ui::ROW_H)
.width(Fill)
]
.spacing(6)
.align_y(iced::Center)
.into()
};
let angle = row![
text(crate::t!("Angle")).size(11).width(43),
text_input("0.0°", &state.angle)
.on_input(|value| msg(GradientMsg::Angle(value)))
.size(11)
.width(Length::Fixed(92.0)),
tooltip(
button(crate::ui::icons::themed_undo(11.0, true))
.on_press(msg(GradientMsg::AngleReset))
.style(button::subtle)
.padding(3)
.width(24)
.height(crate::ui::ROW_H),
text(crate::t!("Reset angle")).size(10),
tooltip::Position::Bottom,
),
Space::new().width(Fill),
checkbox(state.centered)
.label(crate::t!("Centered"))
.on_toggle(|value| msg(GradientMsg::Centered(value)))
.size(14)
.text_size(11)
]
.spacing(6)
.align_y(iced::Center);
let types = row![text(crate::t!("Type")).size(11).width(43), type_row]
.spacing(4)
.align_y(iced::Center);
let actions = container(
row![
button(text(crate::t!("Cancel")).size(11))
.on_press(msg(GradientMsg::Cancel))
.style(button::secondary)
.padding([5, 14]),
button(text(crate::t!("OK")).size(11))
.on_press(msg(GradientMsg::Apply))
.style(button::primary)
.padding([5, 18]),
]
.spacing(8),
)
.width(Fill)
.align_x(iced::alignment::Horizontal::Right);
let content = column![
container(preview(state)).width(Fill).height(80),
row![
segment(crate::t!("One Color"), GradientColorMode::One),
segment(crate::t!("Two Colors"), GradientColorMode::Two)
]
.spacing(2),
colors,
types,
angle,
actions
]
.spacing(8)
.padding(10);
let panel: Element<'_, Message> = container(content)
.width(Length::Fixed(390.0))
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.base.color)),
border: Border {
color: theme.palette().primary.base.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
})
.into();
let panel: Element<'_, Message> = if dimmed {
let shield = container(Space::new())
.width(Fill)
.height(Fill)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.strongest.color.scale_alpha(0.55),
)),
..Default::default()
});
iced::widget::stack![panel, iced::widget::opaque(shield)]
.width(Length::Fixed(390.0))
.into()
} else {
panel
};
mouse_area(panel)
.interaction(iced::mouse::Interaction::Pointer)
.into()
}
File diff suppressed because it is too large. Load diff
+2
View File
@@ -13,6 +13,8 @@ pub mod drawing_units;
pub mod field_dialog;
pub mod find_replace;
pub mod geometric_tolerance;
pub mod gradient_editor;
pub mod graphic_attributes;
pub mod layer_state_manager;
pub mod layer_translator;
pub mod layers;