feat(styles): overhaul style managers

Unify editor layouts, live previews, comparisons, read-only behavior, and localized controls across all named style managers.

Refs #629
This commit is contained in:
Hakan Seven 2026-08-03 02:01:47 +03:00
commit 25515fa33c
27 changed files with 5480 additions and 1636 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,103 +1,383 @@
//! Multiline Style Manager window — fills the entire OS window.
//! Multiline style manager.
use crate::app::Message;
use iced::widget::{column, container, row, scrollable, text};
use iced::{Element, Theme};
use crate::t;
use iced::widget::{button, canvas, checkbox, column, container, row, text, text_input};
use iced::{mouse, Color, Element, Length, Point, Rectangle, Theme};
use std::borrow::Cow;
pub struct MlStyleView<'a> {
pub styles: Vec<String>,
pub selected: &'a str,
pub style: Option<&'a acadrust::objects::MLineStyle>,
pub current: String,
pub tab: u8,
pub compare_name: String,
pub compare_opts: Vec<String>,
pub comparison_sections: Vec<String>,
pub in_use: bool,
pub description: &'a str,
pub start_angle: &'a str,
pub end_angle: &'a str,
pub fill_color: &'a str,
pub elements: &'a [[String; 3]],
pub rename_active: Option<&'a str>,
pub rename_buf: &'a str,
}
fn muted_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.68)),
}
}
pub fn view_window<'a>(
styles: Vec<String>,
selected: &'a str,
selected_style: Option<&'a acadrust::objects::MLineStyle>,
current_style: String,
rename_active: Option<&'a str>,
rename_buf: &'a str,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
// ── Right: Details panel ──────────────────────────────────────────────
let info_row = |label: Cow<'static, str>, val: String| -> Element<'_, Message> {
row![
text(label).size(11).style(muted_style).width(120),
text(val).size(11),
]
.spacing(8)
.align_y(iced::Center)
.into()
};
fn primary_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().primary.base.color),
}
}
let details: Element<'_, Message> = if let Some(s) = selected_style {
let elem_rows: Vec<Element<'_, Message>> = s
fn input_row<'a>(
label: Cow<'static, str>,
placeholder: &'static str,
value: &'a str,
field: &'static str,
) -> Element<'a, Message> {
row![
text(label).size(11).style(muted_style).width(165),
text_input(placeholder, value)
.on_input(move |value| Message::MlStyleEdit { field, value })
.size(11)
.width(190),
]
.spacing(8)
.align_y(iced::Center)
.into()
}
fn toggle<'a>(label: Cow<'static, str>, value: bool, field: &'static str) -> Element<'a, Message> {
checkbox(value)
.label(label)
.on_toggle(move |_| Message::MlStyleToggle(field))
.size(14)
.text_size(11)
.into()
}
fn aci_color(value: &str, fallback: Color) -> Color {
crate::ui::color_select::aci_string_to_color(value)
.rgb()
.map(|(r, g, b)| Color::from_rgb8(r, g, b))
.unwrap_or(fallback)
}
struct MLinePreviewCanvas {
description: String,
start_angle: String,
end_angle: String,
fill_color: String,
fill: bool,
joints: bool,
start_square: bool,
start_inner: bool,
start_round: bool,
end_square: bool,
end_inner: bool,
end_round: bool,
elements: Vec<[String; 3]>,
}
fn element_points(offset: f32, bounds: Rectangle) -> [Point; 3] {
let y = bounds.height * 0.48 - offset * 26.0;
[
Point::new(44.0, y),
Point::new(bounds.width * 0.52, y - 16.0),
Point::new(bounds.width - 44.0, y + 5.0),
]
}
fn draw_arc(frame: &mut canvas::Frame, center: Point, radius: f32, start_side: bool, color: Color) {
let path = canvas::Path::new(|builder| {
for step in 0..=18 {
let t = step as f32 / 18.0;
let angle = if start_side {
std::f32::consts::FRAC_PI_2 + t * std::f32::consts::PI
} else {
-std::f32::consts::FRAC_PI_2 + t * std::f32::consts::PI
};
let point = Point::new(center.x + radius * angle.cos(), center.y + radius * angle.sin());
if step == 0 {
builder.move_to(point);
} else {
builder.line_to(point);
}
}
});
frame.stroke(&path, canvas::Stroke::default().with_color(color).with_width(1.1));
}
impl canvas::Program<Message> for MLinePreviewCanvas {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &iced::Renderer,
theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut frame = canvas::Frame::new(renderer, bounds.size());
let ink = theme.palette().background.base.text;
let mut parsed = self
.elements
.iter()
.enumerate()
.map(|(idx, e)| {
let color_str = match &e.color {
acadrust::types::Color::ByLayer => "ByLayer".into(),
acadrust::types::Color::None => "None".into(),
acadrust::types::Color::ByBlock => "ByBlock".into(),
acadrust::types::Color::Index(i) => format!("ACI {i}"),
acadrust::types::Color::Rgb { r, g, b } => format!("#{r:02X}{g:02X}{b:02X}"),
};
let lt = if e.linetype.is_empty() {
"ByLayer"
} else {
&e.linetype
};
row![
text(format!(" {idx}:")).size(10).style(muted_style).width(24),
text(format!("{:+.3}", e.offset)).size(10).width(70),
text(color_str).size(10).width(90),
text(lt).size(10),
]
.spacing(4)
.align_y(iced::Center)
.into()
.map(|element| {
(
element[0].trim().parse::<f32>().unwrap_or(0.0),
element[1].clone(),
element[2].clone(),
)
})
.collect();
.collect::<Vec<_>>();
parsed.sort_by(|a, b| b.0.total_cmp(&a.0));
if let (Some(top), Some(bottom)) = (parsed.first(), parsed.last()) {
if self.fill {
let upper = element_points(top.0, bounds);
let lower = element_points(bottom.0, bounds);
let fill = canvas::Path::new(|builder| {
builder.move_to(upper[0]);
builder.line_to(upper[1]);
builder.line_to(upper[2]);
builder.line_to(lower[2]);
builder.line_to(lower[1]);
builder.line_to(lower[0]);
builder.close();
});
frame.fill(&fill, aci_color(&self.fill_color, theme.palette().background.weak.color));
}
}
for (offset, color, linetype) in &parsed {
let points = element_points(*offset, bounds);
let dashed = !linetype.trim().is_empty()
&& !linetype.eq_ignore_ascii_case("ByLayer")
&& !linetype.to_ascii_lowercase().contains("continuous");
let stroke = canvas::Stroke {
style: canvas::Style::Solid(aci_color(color, ink)),
width: 1.4,
line_dash: if dashed {
canvas::LineDash { segments: &[6.0, 4.0], offset: 0 }
} else {
canvas::LineDash::default()
},
..Default::default()
};
frame.stroke(
&canvas::Path::new(|builder| {
builder.move_to(points[0]);
builder.line_to(points[1]);
builder.line_to(points[2]);
}),
stroke,
);
}
if let (Some(top), Some(bottom)) = (parsed.first(), parsed.last()) {
let top_points = element_points(top.0, bounds);
let bottom_points = element_points(bottom.0, bounds);
let start_top = top_points[0];
let start_bottom = bottom_points[0];
let end_top = top_points[2];
let end_bottom = bottom_points[2];
let start_angle = self.start_angle.trim().parse::<f32>().unwrap_or(90.0).to_radians();
let end_angle = self.end_angle.trim().parse::<f32>().unwrap_or(90.0).to_radians();
let cap = |a: Point, b: Point, angle: f32| {
let middle = Point::new((a.x + b.x) * 0.5, (a.y + b.y) * 0.5);
let half = ((a.y - b.y).abs() * 0.5).max(2.0);
let direction = Point::new(angle.cos() * half, angle.sin() * half);
(Point::new(middle.x - direction.x, middle.y - direction.y), Point::new(middle.x + direction.x, middle.y + direction.y))
};
if self.start_square {
let (a, b) = cap(start_top, start_bottom, start_angle);
frame.stroke(&canvas::Path::line(a, b), canvas::Stroke::default().with_color(ink));
}
if self.end_square {
let (a, b) = cap(end_top, end_bottom, end_angle);
frame.stroke(&canvas::Path::line(a, b), canvas::Stroke::default().with_color(ink));
}
let start_center = Point::new((start_top.x + start_bottom.x) * 0.5, (start_top.y + start_bottom.y) * 0.5);
let end_center = Point::new((end_top.x + end_bottom.x) * 0.5, (end_top.y + end_bottom.y) * 0.5);
let outer_radius = ((start_top.y - start_bottom.y).abs() * 0.5).max(3.0);
if self.start_round {
draw_arc(&mut frame, start_center, outer_radius, true, ink);
}
if self.end_round {
draw_arc(&mut frame, end_center, outer_radius, false, ink);
}
if self.start_inner && parsed.len() > 2 {
draw_arc(&mut frame, start_center, outer_radius * 0.55, true, ink);
}
if self.end_inner && parsed.len() > 2 {
draw_arc(&mut frame, end_center, outer_radius * 0.55, false, ink);
}
if self.joints {
let joint_top = top_points[1];
let joint_bottom = bottom_points[1];
frame.stroke(&canvas::Path::line(joint_top, joint_bottom), canvas::Stroke::default().with_color(ink).with_width(1.0));
}
}
frame.fill_text(canvas::Text {
content: format!("{} · {} {}", self.description, t!("Elements"), self.elements.len()),
position: Point::new(10.0, bounds.height - 9.0),
color: ink.scale_alpha(0.62),
size: iced::Pixels(10.0),
align_y: iced::alignment::Vertical::Center,
..Default::default()
});
vec![frame.into_geometry()]
}
}
let mut col_items: Vec<Element<'_, Message>> = vec![
info_row(t!("Name:"), s.name.clone()),
info_row(t!("Elements:"), s.elements.len().to_string()),
text(t!(" Off Color Ltype"))
.size(10)
.style(muted_style)
.into(),
];
col_items.extend(elem_rows);
scrollable(column(col_items).spacing(6).padding([12, 12]))
.height(sizing.height)
.into()
} else {
container(text(t!("Select a style to view details.")).size(11).style(muted_style))
.padding([12, 12])
.into()
pub fn view_window<'a>(
v: MlStyleView<'a>,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let style = v.style;
let content: Element<'a, Message> = match (v.tab, style) {
(_, None) => text(t!("Select a style to view details.")).size(11).into(),
(0, Some(_)) => {
let mut items = column![
row![
text(t!("Offset")).size(10).style(muted_style).width(110),
text(t!("Color")).size(10).style(muted_style).width(100),
text(t!("Line type")).size(10).style(muted_style).width(170),
button(text(t!("Add")).size(10)).on_press(Message::MlStyleElementAdd).padding([4, 12]),
]
.spacing(6)
.align_y(iced::Center),
]
.spacing(6);
for (index, element) in v.elements.iter().enumerate() {
items = items.push(
row![
text_input("0.0", &element[0])
.on_input(move |value| Message::MlStyleElementEdit { index, field: "offset", value })
.size(11).width(110),
text_input("256", &element[1])
.on_input(move |value| Message::MlStyleElementEdit { index, field: "color", value })
.size(11).width(100),
text_input("ByLayer", &element[2])
.on_input(move |value| Message::MlStyleElementEdit { index, field: "linetype", value })
.size(11).width(170),
button(text(t!("Delete")).size(10))
.on_press(Message::MlStyleElementDelete(index))
.padding([4, 10]),
]
.spacing(6)
.align_y(iced::Center),
);
}
items.into()
}
(_, Some(style)) => column![
input_row(t!("Description"), "", v.description, "description"),
text(t!("Fill and joints")).size(11).style(primary_style),
row![
toggle(t!("Fill enabled"), style.flags.fill_on, "fill"),
toggle(t!("Display joints"), style.flags.display_joints, "joints"),
].spacing(18),
input_row(t!("Fill color"), "256", v.fill_color, "fill_color"),
text(t!("Start caps")).size(11).style(primary_style),
row![
toggle(t!("Line"), style.flags.start_square_cap, "start_square"),
toggle(t!("Inner arcs"), style.flags.start_inner_arcs_cap, "start_inner"),
toggle(t!("Outer arc"), style.flags.start_round_cap, "start_round"),
].spacing(18),
input_row(t!("Start angle"), "90", v.start_angle, "start_angle"),
text(t!("End caps")).size(11).style(primary_style),
row![
toggle(t!("Line"), style.flags.end_square_cap, "end_square"),
toggle(t!("Inner arcs"), style.flags.end_inner_arcs_cap, "end_inner"),
toggle(t!("Outer arc"), style.flags.end_round_cap, "end_round"),
].spacing(18),
input_row(t!("End angle"), "90", v.end_angle, "end_angle"),
].spacing(9).into(),
};
let right_panel = container(details)
.width(sizing.width)
.height(sizing.height);
let preview: Element<'a, Message> = if let Some(style) = style {
canvas(MLinePreviewCanvas {
description: v.description.to_string(),
start_angle: v.start_angle.to_string(),
end_angle: v.end_angle.to_string(),
fill_color: v.fill_color.to_string(),
fill: style.flags.fill_on,
joints: style.flags.display_joints,
start_square: style.flags.start_square_cap,
start_inner: style.flags.start_inner_arcs_cap,
start_round: style.flags.start_round_cap,
end_square: style.flags.end_square_cap,
end_inner: style.flags.end_inner_arcs_cap,
end_round: style.flags.end_round_cap,
elements: v.elements.to_vec(),
})
.width(Length::Fill)
.height(150)
.into()
} else {
container(text(t!("No preview"))).height(150).into()
};
let status = if v.selected.eq_ignore_ascii_case(&v.current) {
t!("Current style")
} else if v.in_use {
t!("Used in drawing")
} else {
t!("Available style")
};
let summary = if v.comparison_sections.is_empty() {
t!("No differences").into_owned()
} else {
format!("{}: {}", t!("Different"), v.comparison_sections.join(", "))
};
let editor = crate::ui::style::style_manager::editor_shell(
crate::ui::style::style_manager::EditorShell {
sizing,
selected: v.selected.to_string(),
status,
preview,
comparison: Some(crate::ui::style::style_manager::EditorComparison {
selected: v.compare_name,
options: v.compare_opts,
summary,
on_select: Message::MlStyleDialogCompare,
}),
tabs: vec![
crate::ui::style::style_manager::EditorTab {
label: t!("Elements"),
active: v.tab == 0,
on_press: Message::MlStyleDialogTab(0),
},
crate::ui::style::style_manager::EditorTab {
label: t!("Caps and Fill"),
active: v.tab == 1,
on_press: Message::MlStyleDialogTab(1),
},
],
content,
},
);
crate::ui::style::style_manager::view(crate::ui::style::style_manager::Scaffold {
sizing,
kind: crate::app::StyleKind::MLine,
styles: &styles,
selected,
current: Some(current_style.as_str()),
rename_active,
rename_buf,
styles: &v.styles,
selected: v.selected,
current: Some(v.current.as_str()),
rename_active: v.rename_active,
rename_buf: v.rename_buf,
on_new: Message::MlStyleDialogNew,
on_copy: Message::MlStyleDialogCopy,
on_delete: Message::MlStyleDialogDelete,
on_select: Message::MlStyleDialogSelect,
on_set_current: Message::MlStyleDialogSetCurrent,
on_apply: Message::MlStyleApply,
editor: right_panel.into(),
read_only: false,
editor,
})
}

View file

@ -29,6 +29,7 @@ pub fn item<'a>(
on_select: Message,
rename_active: Option<&str>,
rename_buf: &'a str,
can_rename: bool,
) -> Element<'a, Message> {
if rename_active == Some(name) {
text_input("", rename_buf)
@ -56,9 +57,12 @@ pub fn item<'a>(
..Default::default()
}
});
mouse_area(cell)
.on_press(on_select)
.on_double_click(Message::StyleRenameStart(kind, name.to_string()))
.into()
let area = mouse_area(cell).on_press(on_select);
if can_rename {
area.on_double_click(Message::StyleRenameStart(kind, name.to_string()))
.into()
} else {
area.into()
}
}
}

View file

@ -10,7 +10,7 @@
use crate::app::{Message, StyleKind};
use iced::widget::button::{Status, Style};
use iced::widget::{button, column, container, row, scrollable, text, Space};
use iced::{Background, Border, Element, Theme};
use iced::{Background, Border, Element, Length, Theme};
use crate::t;
use std::borrow::Cow;
@ -45,6 +45,9 @@ pub struct Scaffold<'a, 'b> {
pub on_set_current: Message,
/// "Apply" action (right side, primary). Every manager has one.
pub on_apply: Message,
/// Referenced styles may be copied but not changed, renamed, deleted, or
/// made current.
pub read_only: bool,
pub editor: Element<'a, Message>,
}
@ -55,10 +58,10 @@ pub fn view<'a, 'b>(s: Scaffold<'a, 'b>) -> Element<'a, Message> {
let bar = row![
tb_button(t!("New"), s.on_new, false),
tb_button(t!("Copy"), s.on_copy, false),
tb_button(t!("Delete"), s.on_delete, false),
tb_button_enabled(t!("Delete"), s.on_delete, false, !s.read_only),
Space::new().width(width),
tb_button(t!("Set Current"), s.on_set_current, false),
tb_button(t!("Apply"), s.on_apply, true),
tb_button_enabled(t!("Set Current"), s.on_set_current, false, !s.read_only),
tb_button_enabled(t!("Apply"), s.on_apply, true, !s.read_only),
]
.spacing(4)
.align_y(iced::Center);
@ -87,6 +90,7 @@ pub fn view<'a, 'b>(s: Scaffold<'a, 'b>) -> Element<'a, Message> {
(s.on_select)(name.clone()),
s.rename_active,
s.rename_buf,
!(s.read_only && is_sel),
)
})
.collect();
@ -137,6 +141,102 @@ pub fn view<'a, 'b>(s: Scaffold<'a, 'b>) -> Element<'a, Message> {
.into()
}
pub struct EditorTab {
pub label: Cow<'static, str>,
pub active: bool,
pub on_press: Message,
}
pub struct EditorComparison {
pub selected: String,
pub options: Vec<String>,
pub summary: String,
pub on_select: fn(String) -> Message,
}
/// Shared right-hand editor composition used by every named style manager.
/// The preview stays visible while the active property page scrolls.
pub struct EditorShell<'a> {
pub sizing: crate::ui::modal::ModalSizing,
pub selected: String,
pub status: Cow<'static, str>,
pub preview: Element<'a, Message>,
pub comparison: Option<EditorComparison>,
pub tabs: Vec<EditorTab>,
pub content: Element<'a, Message>,
}
pub fn editor_shell<'a>(shell: EditorShell<'a>) -> Element<'a, Message> {
let tabs: Vec<Element<'a, Message>> = shell
.tabs
.into_iter()
.map(|tab| {
button(text(tab.label).size(11))
.on_press(tab.on_press)
.style(tab_button_style(tab.active))
.padding([4, 10])
.into()
})
.collect();
let comparison: Element<'a, Message> = match shell.comparison {
Some(comparison) if !comparison.options.is_empty() => row![
text(t!("Compare with")).size(10).style(muted_text_style),
iced::widget::pick_list(
Some(comparison.selected),
comparison.options,
|value| value.to_string(),
)
.on_select(comparison.on_select)
.text_size(11)
.width(150),
text(comparison.summary).size(10).style(muted_text_style),
]
.spacing(8)
.align_y(iced::Center)
.into(),
_ => Space::new().height(0).into(),
};
let preview = container(shell.preview)
.width(Length::Fill)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.weak.color)),
border: Border {
color: theme.palette().background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
});
container(
column![
row![
text(shell.selected).size(13).style(primary_text_style),
Space::new().width(Length::Fill),
text(shell.status).size(10).style(muted_text_style),
]
.align_y(iced::Center),
preview,
comparison,
row(tabs).spacing(2),
hdivider(Length::Fill),
scrollable(container(shell.content).padding([12, 12]).width(Length::Fill))
.width(Length::Fill)
.height(Length::Fill),
]
.spacing(6)
.height(shell.sizing.height),
)
.height(shell.sizing.height)
.width(Length::Fill)
.padding(iced::Padding {
top: 12.0,
right: 0.0,
bottom: 12.0,
left: 0.0,
})
.into()
}
// ── Shared chrome ──────────────────────────────────────────────────────────
pub(crate) fn tb_button(
@ -152,10 +252,55 @@ pub(crate) fn tb_button(
.into()
}
pub(crate) fn tb_button_enabled(
label: impl Into<Cow<'static, str>>,
msg: Message,
accent: bool,
enabled: bool,
) -> Element<'static, Message> {
let pad = if accent { [4, 14] } else { [4, 10] };
let button = button(text(label.into()).size(11))
.style(btn_s(accent))
.padding(pad);
if enabled {
button.on_press(msg).into()
} else {
button.into()
}
}
fn tab_button_style(active: bool) -> impl Fn(&Theme, Status) -> Style {
move |theme: &Theme, status| {
let palette = theme.palette();
let pair = match (active, status) {
(true, _) => palette.primary.strong,
(false, Status::Hovered | Status::Pressed) => palette.background.strong,
_ => palette.background.weak,
};
Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 3.0.into(),
},
..Default::default()
}
}
}
fn primary_text_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().primary.base.color),
}
}
fn btn_s(accent: bool) -> impl Fn(&Theme, Status) -> Style {
move |theme: &Theme, st| {
let palette = theme.palette();
let pair = match (accent, st) {
(_, Status::Disabled) => palette.background.weak,
(true, Status::Hovered | Status::Pressed) => palette.primary.strong,
(false, Status::Hovered | Status::Pressed) => palette.background.strong,
(true, _) => palette.primary.base,
@ -163,7 +308,11 @@ fn btn_s(accent: bool) -> impl Fn(&Theme, Status) -> Style {
};
Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
text_color: if st == Status::Disabled {
pair.text.scale_alpha(0.45)
} else {
pair.text
},
border: Border {
color: palette.background.neutral.color,
width: 1.0,

View file

@ -1,49 +1,60 @@
//! Table Style Manager window — fills the entire OS window.
//! Table style manager.
use crate::app::Message;
use iced::widget::{
button, checkbox, column, container, row, scrollable, text, text_input, Column,
};
use iced::{Background, Border, Element, Theme};
use crate::t;
use iced::widget::{canvas, checkbox, column, row, text, text_input, Column};
use iced::{mouse, Color, Element, Length, Point, Rectangle, Size, Theme};
use std::borrow::Cow;
use std::fmt;
#[derive(Clone, Debug, Eq, PartialEq)]
struct EnumChoice(String);
struct EnumChoice {
code: String,
label: Cow<'static, str>,
}
impl fmt::Display for EnumChoice {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(crate::i18n::translate(&self.0).as_ref())
formatter.write_str(self.label.as_ref())
}
}
fn enum_choices(values: &[&str]) -> Vec<EnumChoice> {
values.iter().map(|value| EnumChoice((*value).to_string())).collect()
fn choices(values: &[(&str, &str)]) -> Vec<EnumChoice> {
values
.iter()
.map(|(code, label)| EnumChoice {
code: (*code).to_string(),
label: crate::i18n::translate(label),
})
.collect()
}
fn btn_s(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
move |theme: &Theme, st| {
let palette = theme.palette();
let pair = match (accent, st) {
(true, button::Status::Hovered | button::Status::Pressed) => palette.primary.strong,
(false, button::Status::Hovered | button::Status::Pressed) => {
palette.background.strong
}
(true, _) => palette.primary.base,
_ => palette.background.weak,
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
}
}
pub struct TableStyleView<'a> {
pub styles: Vec<String>,
pub selected: &'a str,
pub current: &'a str,
pub style: Option<&'a acadrust::objects::TableStyle>,
pub tab: u8,
pub compare_name: String,
pub compare_opts: Vec<String>,
pub comparison_sections: Vec<String>,
pub in_use: bool,
pub hmargin: &'a str,
pub vmargin: &'a str,
pub description: &'a str,
pub cell_textstyle: &'a [String; 3],
pub cell_height: &'a [String; 3],
pub cell_textcolor: &'a [String; 3],
pub cell_fillcolor: &'a [String; 3],
pub cell_datatype: &'a [String; 3],
pub cell_unittype: &'a [String; 3],
pub cell_format: &'a [String; 3],
pub border_lw: &'a [[String; 6]; 3],
pub border_color: &'a [[String; 6]; 3],
pub border_spacing: &'a [[String; 6]; 3],
pub rename_active: Option<&'a str>,
pub rename_buf: &'a str,
pub color_open: Option<(u8, &'static str)>,
}
fn muted_style(theme: &Theme) -> iced::widget::text::Style {
@ -58,323 +69,492 @@ fn primary_style(theme: &Theme) -> iced::widget::text::Style {
}
}
pub fn view_window<'a>(
styles: Vec<String>,
selected: &'a str,
current: &'a str,
selected_style: Option<&'a acadrust::objects::TableStyle>,
hmargin_buf: &'a str,
vmargin_buf: &'a str,
description_buf: &'a str,
cell_textstyle: &'a [String; 3],
cell_height: &'a [String; 3],
cell_textcolor: &'a [String; 3],
cell_fillcolor: &'a [String; 3],
cell_datatype: &'a [String; 3],
cell_unittype: &'a [String; 3],
cell_format: &'a [String; 3],
border_lw: &'a [[String; 6]; 3],
border_color: &'a [[String; 6]; 3],
border_spacing: &'a [[String; 6]; 3],
rename_active: Option<&'a str>,
rename_buf: &'a str,
color_open: Option<(u8, &'static str)>,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
// ── Right: Details panel ──────────────────────────────────────────────
let info_row = |label: Cow<'static, str>, val: String| -> Element<'_, Message> {
row![
text(label).size(11).style(muted_style).width(160),
text(val).size(11),
]
.spacing(8)
.align_y(iced::Center)
.into()
};
#[derive(Clone)]
struct PreviewBorder {
color: String,
weight: String,
spacing: String,
double: bool,
hidden: bool,
}
let cell_editor = |row_label: Cow<'static, str>,
row: u8,
rs: &acadrust::objects::RowCellStyle|
-> Element<'a, Message> {
let r = row as usize;
let cell_in = |label: Cow<'static, str>,
placeholder: &'static str,
value: &'a str,
field: &'static str|
-> Element<'a, Message> {
row![
text(label).size(11).style(muted_style).width(150),
text_input(placeholder, value)
.on_input(move |v| Message::TableStyleCellEdit {
row,
field,
value: v
})
.size(11)
.width(100),
]
#[derive(Clone)]
struct PreviewRow {
label: String,
text_style: String,
text_height: String,
text_color: String,
fill_color: String,
fill: bool,
alignment: String,
data_type: String,
unit_type: String,
format: String,
borders: [PreviewBorder; 6],
}
struct TablePreviewCanvas {
rows: [PreviewRow; 3],
active_row: usize,
hmargin: String,
vmargin: String,
description: String,
flow_up: bool,
title_suppressed: bool,
header_suppressed: bool,
annotative: bool,
}
fn aci_color(value: &str, fallback: Color) -> Color {
crate::ui::color_select::aci_string_to_color(value)
.rgb()
.map(|(r, g, b)| Color::from_rgb8(r, g, b))
.unwrap_or(fallback)
}
fn border_width(value: &str) -> f32 {
value
.trim()
.parse::<f32>()
.map(|value| (value.abs() / 35.0).clamp(0.7, 4.5))
.unwrap_or(1.0)
}
fn draw_line(
frame: &mut canvas::Frame,
a: Point,
b: Point,
border: &PreviewBorder,
fallback: Color,
) {
if border.hidden {
return;
}
let stroke = canvas::Stroke::default()
.with_color(aci_color(&border.color, fallback))
.with_width(border_width(&border.weight));
frame.stroke(&canvas::Path::line(a, b), stroke.clone());
if border.double {
let spacing = border
.spacing
.trim()
.parse::<f32>()
.unwrap_or(1.0)
.abs()
.clamp(1.0, 5.0);
let dx = b.x - a.x;
let dy = b.y - a.y;
let length = (dx * dx + dy * dy).sqrt().max(1.0);
let offset = Point::new(-dy / length * spacing, dx / length * spacing);
frame.stroke(
&canvas::Path::line(
Point::new(a.x + offset.x, a.y + offset.y),
Point::new(b.x + offset.x, b.y + offset.y),
),
stroke,
);
}
}
impl canvas::Program<Message> for TablePreviewCanvas {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &iced::Renderer,
theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut frame = canvas::Frame::new(renderer, bounds.size());
let ink = theme.palette().background.base.text;
let mut order = vec![2usize, 1, 0];
if self.title_suppressed {
order.retain(|row| *row != 2);
}
if self.header_suppressed {
order.retain(|row| *row != 1);
}
if self.flow_up {
order.reverse();
}
let hmargin = self.hmargin.trim().parse::<f32>().unwrap_or(1.5).abs();
let vmargin = self.vmargin.trim().parse::<f32>().unwrap_or(1.5).abs();
let left = 18.0 + (hmargin * 1.2).clamp(0.0, 18.0);
let right = bounds.width - left;
let top = 9.0 + (vmargin * 0.8).clamp(0.0, 12.0);
let bottom = bounds.height - 24.0;
let row_height = ((bottom - top) / order.len().max(1) as f32).max(18.0);
let col_width = (right - left) / 3.0;
for (display_row, row_index) in order.iter().copied().enumerate() {
let row = &self.rows[row_index];
let y0 = top + display_row as f32 * row_height;
let y1 = y0 + row_height;
let fallback_fill = theme.palette().background.weak.color;
if row.fill {
frame.fill(
&canvas::Path::rectangle(
Point::new(left, y0),
Size::new(right - left, row_height),
),
aci_color(&row.fill_color, fallback_fill),
);
}
let borders = &row.borders;
draw_line(&mut frame, Point::new(left, y0), Point::new(left, y1), &borders[0], ink);
draw_line(&mut frame, Point::new(right, y0), Point::new(right, y1), &borders[1], ink);
draw_line(&mut frame, Point::new(left, y0), Point::new(right, y0), &borders[2], ink);
draw_line(&mut frame, Point::new(left, y1), Point::new(right, y1), &borders[3], ink);
for column in 1..3 {
let x = left + column as f32 * col_width;
draw_line(&mut frame, Point::new(x, y0), Point::new(x, y1), &borders[5], ink);
}
if display_row + 1 < order.len() {
draw_line(&mut frame, Point::new(left, y1), Point::new(right, y1), &borders[4], ink);
}
for column in 0..3 {
let label = if row.format.trim().is_empty() {
format!("{} {}", row.label, column + 1)
} else {
row.format.replace("{}", &(column + 1).to_string())
};
let horizontal = if row.alignment.ends_with("Left") {
iced::advanced::text::Alignment::Left
} else if row.alignment.ends_with("Right") {
iced::advanced::text::Alignment::Right
} else {
iced::advanced::text::Alignment::Center
};
let x = match horizontal {
iced::advanced::text::Alignment::Left => left + column as f32 * col_width + 5.0,
iced::advanced::text::Alignment::Right => left + (column + 1) as f32 * col_width - 5.0,
iced::advanced::text::Alignment::Center => left + (column as f32 + 0.5) * col_width,
_ => left + (column as f32 + 0.5) * col_width,
};
let y = if row.alignment.starts_with("Top") {
y0 + 7.0
} else if row.alignment.starts_with("Bottom") {
y1 - 7.0
} else {
(y0 + y1) * 0.5
};
frame.fill_text(canvas::Text {
content: label,
position: Point::new(x, y),
color: aci_color(&row.text_color, ink),
size: iced::Pixels(
row.text_height
.trim()
.parse::<f32>()
.map(|height| (height * 34.0).clamp(7.0, 16.0))
.unwrap_or(10.0),
),
align_x: horizontal,
align_y: iced::alignment::Vertical::Center,
..Default::default()
});
}
}
let active = &self.rows[self.active_row.min(2)];
frame.fill_text(canvas::Text {
content: format!(
"{} · {} · type {} / unit {}{}{}",
active.text_style,
if self.annotative { t!("Annotative") } else { t!("Drawing units") },
active.data_type,
active.unit_type,
if self.description.trim().is_empty() { "" } else { " · " },
self.description,
),
position: Point::new(10.0, bounds.height - 9.0),
color: ink.scale_alpha(0.65),
size: iced::Pixels(10.0),
align_y: iced::alignment::Vertical::Center,
..Default::default()
});
vec![frame.into_geometry()]
}
}
fn input_row<'a>(
label: Cow<'static, str>,
placeholder: &'static str,
value: &'a str,
message: impl Fn(String) -> Message + 'a,
) -> Element<'a, Message> {
row![
text(label).size(11).style(muted_style).width(155),
text_input(placeholder, value)
.on_input(message)
.size(11)
.width(170),
]
.spacing(8)
.align_y(iced::Center)
.into()
}
fn cell_editor<'a>(v: &TableStyleView<'a>, row_index: u8) -> Element<'a, Message> {
let Some(style) = v.style else {
return text(t!("Select a style to view details.")).into();
};
let row_style = match row_index {
0 => &style.data_row_style,
1 => &style.header_row_style,
_ => &style.title_row_style,
};
let index = row_index as usize;
let cell_input = |label, placeholder, value: &'a str, field| {
input_row(label, placeholder, value, move |value| Message::TableStyleCellEdit {
row: row_index,
field,
value,
})
};
let cell_color = |label: Cow<'static, str>, value: &'a str, field: &'static str| {
let current = crate::ui::color_select::aci_string_to_color(value);
let selector = crate::ui::color_select::color_selector(
current,
v.color_open == Some((row_index, field)),
crate::ui::color_select::ColorExtras { by_layer: true, by_block: true },
move |color| Message::TableStyleCellEdit {
row: row_index,
field,
value: crate::ui::color_select::color_to_aci_string(color),
},
Message::TableColorMore(row_index, field),
Message::OpenColorWindow(crate::app::ColorPickTarget::Table(row_index, field), current),
);
row![text(label).size(11).style(muted_style).width(155), selector]
.spacing(8)
.align_y(iced::Center)
.into()
};
// Shared colour selector — sends the chosen colour through the same
// cell edit as an ACI string.
let cell_color = |label: Cow<'static, str>, value: &'a str, field: &'static str| -> Element<'a, Message> {
let cur = crate::ui::color_select::aci_string_to_color(value);
let open = color_open == Some((row, field));
let selector = crate::ui::color_select::color_selector(
cur,
open,
crate::ui::color_select::ColorExtras {
by_layer: true,
by_block: true,
},
move |c| Message::TableStyleCellEdit {
row,
field,
value: crate::ui::color_select::color_to_aci_string(c),
},
Message::TableColorMore(row, field),
Message::OpenColorWindow(
crate::app::ColorPickTarget::Table(row, field),
cur,
),
);
row![text(label).size(11).style(muted_style).width(150), selector]
.spacing(8)
.align_y(iced::Center)
.into()
};
let mut col = Column::new()
.spacing(3)
.push(text(row_label).size(11).style(primary_style))
.push(cell_in(
t!(" Text style:"),
"Standard",
&cell_textstyle[r],
"textstyle",
))
.push(cell_in(t!(" Text height:"), "0.18", &cell_height[r], "height"))
.push(cell_color(t!(" Text color:"), &cell_textcolor[r], "textcolor"))
.push(cell_color(t!(" Fill color:"), &cell_fillcolor[r], "fillcolor"))
.push(
row![
text(t!(" Alignment:")).size(11).style(muted_style).width(150),
iced::widget::pick_list(
Some(EnumChoice(format!("{:?}", rs.alignment))),
enum_choices(&[
"TopLeft",
"TopCenter",
"TopRight",
"MiddleLeft",
"MiddleCenter",
"MiddleRight",
"BottomLeft",
"BottomCenter",
"BottomRight",
]),
|value| value.to_string(),
)
.on_select(move |choice| Message::TableStyleCellSetAlign {
row,
value: choice.0,
})
.text_size(11)
.width(140),
]
.spacing(8)
.align_y(iced::Center),
)
.push(
checkbox(rs.fill_enabled)
.label(t!(" Background fill enabled"))
.on_toggle(move |_| Message::TableStyleCellToggleFill(row))
.size(14)
.text_size(11),
)
.push(cell_in(t!(" Data type:"), "0", &cell_datatype[r], "datatype"))
.push(cell_in(t!(" Unit type:"), "0", &cell_unittype[r], "unittype"))
.push(cell_in(t!(" Format string:"), "", &cell_format[r], "format"))
.push(
text(t!(" Borders (type / weight / color / spacing / hidden)"))
.size(10)
.style(muted_style),
);
let borders: [(&'static str, &acadrust::objects::TableCellBorder); 6] = [
("L", &rs.left_border),
("R", &rs.right_border),
("T", &rs.top_border),
("B", &rs.bottom_border),
("H", &rs.horizontal_inside_border),
("V", &rs.vertical_inside_border),
];
for (b, (bname, bd)) in borders.into_iter().enumerate() {
let bu = b as u8;
col = col.push(
row![
text(format!(" {bname}")).size(11).style(muted_style).width(28),
iced::widget::pick_list(
Some(EnumChoice(format!("{:?}", bd.border_type))),
enum_choices(&["Single", "Double"]),
|value| value.to_string(),
)
.on_select(move |choice| Message::TableStyleBorderSetType {
cell: row,
border: bu,
value: choice.0,
})
.text_size(10)
.width(74),
text_input("", &border_lw[r][b])
.on_input(move |v| Message::TableStyleBorderEdit {
cell: row,
border: bu,
field: "lw",
value: v
})
.size(10)
.width(46),
text_input("", &border_color[r][b])
.on_input(move |v| Message::TableStyleBorderEdit {
cell: row,
border: bu,
field: "color",
value: v
})
.size(10)
.width(46),
text_input("", &border_spacing[r][b])
.on_input(move |v| Message::TableStyleBorderEdit {
cell: row,
border: bu,
field: "spacing",
value: v
})
.size(10)
.width(46),
checkbox(bd.is_invisible)
.on_toggle(move |_| Message::TableStyleBorderToggleInvisible {
cell: row,
border: bu
})
.size(13),
]
.spacing(5)
.align_y(iced::Center),
);
}
col.push(
button(text(t!("Apply cell")).size(11))
.on_press(Message::TableStyleCellApply(row))
.style(btn_s(true))
.padding([4, 12]),
)
.into()
};
let details: Element<'_, Message> = if let Some(s) = selected_style {
scrollable(
column![
info_row(t!("Name:"), s.name.clone()),
row![
text(t!("Description:")).size(11).style(muted_style).width(160),
text_input("", description_buf)
.on_input(|v| Message::TableStyleEdit {
field: "description",
value: v
})
.size(11)
.width(160),
]
.spacing(8)
.align_y(iced::Center),
row![
text(t!("Flow direction:")).size(11).style(muted_style).width(160),
iced::widget::pick_list(
Some(EnumChoice(format!("{:?}", s.flow_direction))),
enum_choices(&["Down", "Up"]),
|value| value.to_string(),
)
.on_select(|choice| Message::TableStyleSetFlow(choice.0))
.text_size(11)
.width(100),
]
.spacing(8)
.align_y(iced::Center),
row![
text(t!("H Margin:")).size(11).style(muted_style).width(160),
text_input("1.5", hmargin_buf)
.on_input(|v| Message::TableStyleEdit {
field: "hmargin",
value: v
})
.size(11)
.width(100),
]
.spacing(8)
.align_y(iced::Center),
row![
text(t!("V Margin:")).size(11).style(muted_style).width(160),
text_input("1.5", vmargin_buf)
.on_input(|v| Message::TableStyleEdit {
field: "vmargin",
value: v
})
.size(11)
.width(100),
]
.spacing(8)
.align_y(iced::Center),
checkbox(s.title_suppressed)
.label(t!("Title row suppressed"))
.on_toggle(|_| Message::TableStyleToggle("title_sup"))
.size(14)
.text_size(11),
checkbox(s.header_suppressed)
.label(t!("Header row suppressed"))
.on_toggle(|_| Message::TableStyleToggle("header_sup"))
.size(14)
.text_size(11),
button(text(t!("Apply margins")).size(11))
.on_press(Message::TableStyleApply)
.style(btn_s(true))
.padding([4, 12]),
cell_editor(t!("Data Row:"), 0, &s.data_row_style),
cell_editor(t!("Header Row:"), 1, &s.header_row_style),
cell_editor(t!("Title Row:"), 2, &s.title_row_style),
let selected_alignment = format!("{:?}", row_style.alignment);
let mut content = Column::new()
.spacing(7)
.push(cell_input(t!("Text style"), "Standard", &v.cell_textstyle[index], "textstyle"))
.push(cell_input(t!("Text height"), "0.18", &v.cell_height[index], "height"))
.push(cell_color(t!("Text color"), &v.cell_textcolor[index], "textcolor"))
.push(cell_color(t!("Fill color"), &v.cell_fillcolor[index], "fillcolor"))
.push(
row![
text(t!("Alignment")).size(11).style(muted_style).width(155),
iced::widget::pick_list(
Some(EnumChoice { code: selected_alignment.clone(), label: crate::i18n::translate(&selected_alignment) }),
choices(&[
("TopLeft", "Top left"), ("TopCenter", "Top center"), ("TopRight", "Top right"),
("MiddleLeft", "Middle left"), ("MiddleCenter", "Middle center"), ("MiddleRight", "Middle right"),
("BottomLeft", "Bottom left"), ("BottomCenter", "Bottom center"), ("BottomRight", "Bottom right"),
]),
|value| value.to_string(),
)
.on_select(move |choice| Message::TableStyleCellSetAlign { row: row_index, value: choice.code })
.text_size(11)
.width(170),
]
.spacing(6)
.padding([12, 12]),
.spacing(8)
.align_y(iced::Center),
)
.width(sizing.width)
.height(sizing.height)
.into()
} else {
container(text(t!("Select a style to view details.")).size(11).style(muted_style))
.padding([12, 12])
.into()
.push(
checkbox(row_style.fill_enabled)
.label(t!("Background fill enabled"))
.on_toggle(move |_| Message::TableStyleCellToggleFill(row_index))
.size(14)
.text_size(11),
)
.push(cell_input(t!("Data type"), "0", &v.cell_datatype[index], "datatype"))
.push(cell_input(t!("Unit type"), "0", &v.cell_unittype[index], "unittype"))
.push(cell_input(t!("Format string"), "", &v.cell_format[index], "format"))
.push(text(t!("Borders")).size(11).style(primary_style));
let borders = [
(t!("Left"), &row_style.left_border),
(t!("Right"), &row_style.right_border),
(t!("Top"), &row_style.top_border),
(t!("Bottom"), &row_style.bottom_border),
(t!("Inside horizontal"), &row_style.horizontal_inside_border),
(t!("Inside vertical"), &row_style.vertical_inside_border),
];
for (border_index, (label, border)) in borders.into_iter().enumerate() {
let border_index = border_index as u8;
let border_type = format!("{:?}", border.border_type);
content = content.push(
row![
text(label).size(10).style(muted_style).width(96),
iced::widget::pick_list(
Some(EnumChoice { code: border_type.clone(), label: crate::i18n::translate(&border_type) }),
choices(&[("Single", "Single"), ("Double", "Double")]),
|value| value.to_string(),
)
.on_select(move |choice| Message::TableStyleBorderSetType { cell: row_index, border: border_index, value: choice.code })
.text_size(10)
.width(82),
text_input(t!("Weight").as_ref(), &v.border_lw[index][border_index as usize])
.on_input(move |value| Message::TableStyleBorderEdit { cell: row_index, border: border_index, field: "lw", value })
.size(10).width(68),
text_input(t!("Color").as_ref(), &v.border_color[index][border_index as usize])
.on_input(move |value| Message::TableStyleBorderEdit { cell: row_index, border: border_index, field: "color", value })
.size(10).width(62),
text_input(t!("Spacing").as_ref(), &v.border_spacing[index][border_index as usize])
.on_input(move |value| Message::TableStyleBorderEdit { cell: row_index, border: border_index, field: "spacing", value })
.size(10).width(68),
checkbox(border.is_invisible)
.label(t!("Hidden"))
.on_toggle(move |_| Message::TableStyleBorderToggleInvisible { cell: row_index, border: border_index })
.size(13).text_size(10),
]
.spacing(5)
.align_y(iced::Center),
);
}
content.into()
}
fn preview_rows(v: &TableStyleView<'_>) -> Option<[PreviewRow; 3]> {
let style = v.style?;
let source = [&style.data_row_style, &style.header_row_style, &style.title_row_style];
Some(std::array::from_fn(|row| {
let borders = [
&source[row].left_border,
&source[row].right_border,
&source[row].top_border,
&source[row].bottom_border,
&source[row].horizontal_inside_border,
&source[row].vertical_inside_border,
];
PreviewRow {
label: [t!("Data"), t!("Header"), t!("Title")][row].to_string(),
text_style: v.cell_textstyle[row].clone(),
text_height: v.cell_height[row].clone(),
text_color: v.cell_textcolor[row].clone(),
fill_color: v.cell_fillcolor[row].clone(),
fill: source[row].fill_enabled,
alignment: format!("{:?}", source[row].alignment),
data_type: v.cell_datatype[row].clone(),
unit_type: v.cell_unittype[row].clone(),
format: v.cell_format[row].clone(),
borders: std::array::from_fn(|border| PreviewBorder {
color: v.border_color[row][border].clone(),
weight: v.border_lw[row][border].clone(),
spacing: v.border_spacing[row][border].clone(),
double: format!("{:?}", borders[border].border_type) == "Double",
hidden: borders[border].is_invisible,
}),
}
}))
}
pub fn view_window<'a>(
v: TableStyleView<'a>,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let style = v.style;
let content: Element<'a, Message> = match (v.tab, style) {
(_, None) => text(t!("Select a style to view details.")).size(11).into(),
(0, Some(style)) => column![
input_row(t!("Description"), "", v.description, |value| Message::TableStyleEdit { field: "description", value }),
row![
text(t!("Flow direction")).size(11).style(muted_style).width(155),
iced::widget::pick_list(
Some(EnumChoice { code: format!("{:?}", style.flow_direction), label: crate::i18n::translate(&format!("{:?}", style.flow_direction)) }),
choices(&[("Down", "Top to bottom"), ("Up", "Bottom to top")]),
|value| value.to_string(),
)
.on_select(|choice| Message::TableStyleSetFlow(choice.code))
.text_size(11).width(170),
]
.spacing(8).align_y(iced::Center),
input_row(t!("Horizontal margin"), "1.5", v.hmargin, |value| Message::TableStyleEdit { field: "hmargin", value }),
input_row(t!("Vertical margin"), "1.5", v.vmargin, |value| Message::TableStyleEdit { field: "vmargin", value }),
checkbox(style.title_suppressed).label(t!("Suppress title row")).on_toggle(|_| Message::TableStyleToggle("title_sup")).size(14).text_size(11),
checkbox(style.header_suppressed).label(t!("Suppress header row")).on_toggle(|_| Message::TableStyleToggle("header_sup")).size(14).text_size(11),
checkbox(style.annotative).label(t!("Annotative")).on_toggle(|_| Message::TableStyleToggleAnnotative).size(14).text_size(11),
]
.spacing(9)
.into(),
(tab, Some(_)) => cell_editor(&v, tab.saturating_sub(1).min(2)),
};
let right_panel = container(details)
.width(sizing.width)
.height(sizing.height);
let rows = preview_rows(&v).unwrap_or_else(|| {
std::array::from_fn(|row| PreviewRow {
label: ["Data", "Header", "Title"][row].to_string(),
text_style: String::new(),
text_height: String::new(),
text_color: String::new(),
fill_color: String::new(),
fill: false,
alignment: "MiddleCenter".to_string(),
data_type: String::new(),
unit_type: String::new(),
format: String::new(),
borders: std::array::from_fn(|_| PreviewBorder {
color: String::new(), weight: String::new(), spacing: String::new(), double: false, hidden: false,
}),
})
});
let preview = canvas(TablePreviewCanvas {
rows,
active_row: v.tab.saturating_sub(1).min(2) as usize,
hmargin: v.hmargin.to_string(),
vmargin: v.vmargin.to_string(),
description: v.description.to_string(),
flow_up: style.is_some_and(|style| format!("{:?}", style.flow_direction) == "Up"),
title_suppressed: style.is_some_and(|style| style.title_suppressed),
header_suppressed: style.is_some_and(|style| style.header_suppressed),
annotative: style.is_some_and(|style| style.annotative),
})
.width(Length::Fill)
.height(150);
let status = if v.selected.eq_ignore_ascii_case(v.current) {
t!("Current style")
} else if v.in_use {
t!("Used in drawing")
} else {
t!("Available style")
};
let summary = if v.comparison_sections.is_empty() {
t!("No differences").into_owned()
} else {
format!("{}: {}", t!("Different"), v.comparison_sections.join(", "))
};
let editor = crate::ui::style::style_manager::editor_shell(
crate::ui::style::style_manager::EditorShell {
sizing,
selected: v.selected.to_string(),
status,
preview: preview.into(),
comparison: Some(crate::ui::style::style_manager::EditorComparison {
selected: v.compare_name,
options: v.compare_opts,
summary,
on_select: Message::TableStyleDialogCompare,
}),
tabs: [t!("General"), t!("Data Row"), t!("Header Row"), t!("Title Row")]
.into_iter()
.enumerate()
.map(|(tab, label)| crate::ui::style::style_manager::EditorTab {
label,
active: v.tab == tab as u8,
on_press: Message::TableStyleDialogTab(tab as u8),
})
.collect(),
content,
},
);
crate::ui::style::style_manager::view(crate::ui::style::style_manager::Scaffold {
sizing,
kind: crate::app::StyleKind::Table,
styles: &styles,
selected,
current: Some(current),
rename_active,
rename_buf,
styles: &v.styles,
selected: v.selected,
current: Some(v.current),
rename_active: v.rename_active,
rename_buf: v.rename_buf,
on_new: Message::TableStyleDialogNew,
on_copy: Message::TableStyleDialogCopy,
on_delete: Message::TableStyleDialogDelete,
on_select: Message::TableStyleDialogSelect,
on_set_current: Message::TableStyleDialogSetCurrent,
on_apply: Message::TableStyleApply,
editor: right_panel.into(),
read_only: false,
editor,
})
}

View file

@ -1,20 +1,21 @@
//! Text Style Font Browser window — fills the entire OS window.
//! Text style manager.
use crate::app::Message;
use crate::app::StyleKind;
use iced::widget::{
button, canvas, checkbox, column, container, row, scrollable, text, text_input, Space,
};
use iced::{mouse, Background, Border, Element, Length, Point, Rectangle, Theme};
use crate::app::{Message, StyleKind};
use crate::t;
use iced::widget::{button, canvas, checkbox, column, container, row, scrollable, text, text_input};
use iced::{mouse, Background, Border, Element, Length, Point, Rectangle, Theme};
use std::borrow::Cow;
/// View-model for the Text Style editor window.
pub struct TextStyleView<'a> {
pub styles: Vec<String>,
pub selected: &'a str,
/// Name of the current text style (marked with ✓ in the list).
pub current: &'a str,
pub tab: u8,
pub compare_name: String,
pub compare_opts: Vec<String>,
pub comparison_sections: Vec<String>,
pub read_only: bool,
pub in_use: bool,
pub font_buf: &'a str,
pub width_buf: &'a str,
pub oblique_buf: &'a str,
@ -23,57 +24,18 @@ pub struct TextStyleView<'a> {
pub ttf_buf: &'a str,
pub backward: bool,
pub upside_down: bool,
pub vertical: bool,
pub annotative: bool,
/// Name of the style being renamed inline (double-clicked), if any.
pub rename_active: Option<&'a str>,
/// Edit buffer for the inline rename text input.
pub rename_buf: &'a str,
}
const BUILTIN_FONTS: &[&str] = &[
"Standard", "ISO", "Simplex", "RomanS", "RomanD", "RomanC", "RomanT", "ItalicC", "ItalicT",
"ScriptS", "ScriptC", "GothGBT", "GothGRT", "GothITT", "GreekC", "Symbol",
"ISO3098", "Unicode",
"Standard", "ISO", "Simplex", "RomanS", "RomanD", "RomanC", "RomanT", "ItalicC",
"ItalicT", "ScriptS", "ScriptC", "GothGBT", "GothGRT", "GothITT", "GreekC",
"Symbol", "ISO3098", "Unicode",
];
fn list_item(active: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
move |theme: &Theme, st| {
let palette = theme.palette();
let pair = match (active, st) {
(true, _) => Some(palette.primary.strong),
(false, button::Status::Hovered | button::Status::Pressed) => {
Some(palette.background.strong)
}
_ => None,
};
button::Style {
background: pair.map(|p| Background::Color(p.color)),
text_color: pair.map(|p| p.text).unwrap_or(palette.background.base.text),
..Default::default()
}
}
}
fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
let palette = theme.palette();
let border = match status {
text_input::Status::Focused { .. } => palette.primary.base.color,
_ => palette.background.neutral.color,
};
text_input::Style {
background: Background::Color(palette.background.base.color),
border: Border {
color: border,
width: 1.0,
radius: 3.0.into(),
},
icon: palette.background.base.text,
placeholder: palette.background.base.text.scale_alpha(0.48),
value: palette.background.base.text,
selection: palette.primary.base.color.scale_alpha(0.5),
}
}
fn muted_style(theme: &Theme) -> iced::widget::text::Style {
iced::widget::text::Style {
color: Some(theme.palette().background.base.text.scale_alpha(0.68)),
@ -86,33 +48,55 @@ fn primary_style(theme: &Theme) -> iced::widget::text::Style {
}
}
fn vsep<'a>(height: Length) -> Element<'a, Message> {
container(Space::new().width(1).height(height))
.width(1)
.height(height)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.neutral.color
)),
..Default::default()
})
.into()
fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
let palette = theme.palette();
text_input::Style {
background: Background::Color(palette.background.base.color),
border: Border {
color: if matches!(status, text_input::Status::Focused { .. }) {
palette.primary.base.color
} else {
palette.background.neutral.color
},
width: 1.0,
radius: 3.0.into(),
},
icon: palette.background.base.text,
placeholder: palette.background.base.text.scale_alpha(0.48),
value: palette.background.base.text,
selection: palette.primary.base.color.scale_alpha(0.5),
}
}
fn list_item(active: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
move |theme: &Theme, status| {
let palette = theme.palette();
let pair = match (active, status) {
(true, _) => Some(palette.primary.strong),
(false, button::Status::Hovered | button::Status::Pressed) => {
Some(palette.background.strong)
}
_ => None,
};
button::Style {
background: pair.map(|value| Background::Color(value.color)),
text_color: pair
.map(|value| value.text)
.unwrap_or(palette.background.base.text),
..Default::default()
}
}
}
/// Live sample-text preview. Tessellates a fixed sample string through the same
/// stroke path the text entities use (`lff::tessellate_text_ex`), so the chosen
/// font, width factor, oblique angle and backward/upside-down flags all show up,
/// then scales the result to fit the preview box (Y flipped: glyph space is
/// Y-up, screen is Y-down). Height is not bound — it only scales uniformly and
/// the fit cancels it.
struct TextPreviewCanvas {
font: String,
/// Negative mirrors left-right (backward).
big_font: String,
width_factor: f32,
/// Radians.
oblique: f32,
/// 0 or π (upside-down rotates 180° about the origin).
rotation: f32,
height: f32,
upside_down: bool,
vertical: bool,
annotative: bool,
}
impl canvas::Program<Message> for TextPreviewCanvas {
@ -127,314 +111,311 @@ impl canvas::Program<Message> for TextPreviewCanvas {
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut frame = canvas::Frame::new(renderer, bounds.size());
let (strokes, _) = crate::scene::text::lff::tessellate_text_ex(
[0.0, 0.0],
9.0,
self.rotation,
self.width_factor,
self.oblique,
&self.font,
"AaBbCc 0123",
);
let ink = theme.palette().background.base.text;
let glyph_height = if self.height <= 0.0 {
18.0
} else {
(self.height * 7.0).clamp(6.0, 52.0)
};
let rotation = if self.upside_down {
std::f32::consts::PI
} else {
0.0
};
let strokes = if self.vertical {
let mut result = Vec::new();
for (index, character) in "Ab3".chars().enumerate() {
let (mut glyph, _) = crate::scene::text::lff::tessellate_text_ex(
[0.0, -(index as f32) * glyph_height * 1.25],
glyph_height,
rotation,
self.width_factor,
self.oblique,
&self.font,
&character.to_string(),
);
result.append(&mut glyph);
}
result
} else {
crate::scene::text::lff::tessellate_text_ex(
[0.0, 0.0],
glyph_height,
rotation,
self.width_factor,
self.oblique,
&self.font,
"AaBbCc 0123",
)
.0
};
let mut min = [f32::MAX; 2];
let mut max = [f32::MIN; 2];
for s in &strokes {
for &[x, y] in s {
for stroke in &strokes {
for &[x, y] in stroke {
min[0] = min[0].min(x);
min[1] = min[1].min(y);
max[0] = max[0].max(x);
max[1] = max[1].max(y);
}
}
if min[0] > max[0] {
return vec![frame.into_geometry()];
}
let pad = 10.0;
let span = [(max[0] - min[0]).max(1e-3), (max[1] - min[1]).max(1e-3)];
let scale = ((bounds.width - 2.0 * pad) / span[0])
.min((bounds.height - 2.0 * pad) / span[1])
.max(0.0);
let mid = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5];
let center = [bounds.width * 0.5, bounds.height * 0.5];
let map = |x: f32, y: f32| {
Point::new(
center[0] + (x - mid[0]) * scale,
center[1] - (y - mid[1]) * scale,
)
};
let stroke = canvas::Stroke {
width: 1.4,
style: canvas::Style::Solid(theme.palette().background.base.text),
..Default::default()
};
for s in &strokes {
if s.len() < 2 {
continue;
}
let path = canvas::Path::new(|p| {
p.move_to(map(s[0][0], s[0][1]));
for &[x, y] in &s[1..] {
p.line_to(map(x, y));
if min[0] <= max[0] {
let span = [(max[0] - min[0]).max(1.0), (max[1] - min[1]).max(1.0)];
let available = [bounds.width - 24.0, bounds.height - 34.0];
let scale = (available[0] / span[0])
.min(available[1] / span[1])
.min(1.0)
.max(0.0);
let middle = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5];
let center = [bounds.width * 0.5, (bounds.height - 18.0) * 0.5];
let map = |x: f32, y: f32| {
Point::new(
center[0] + (x - middle[0]) * scale,
center[1] - (y - middle[1]) * scale,
)
};
for stroke in &strokes {
if stroke.len() < 2 {
continue;
}
});
frame.stroke(&path, stroke.clone());
let path = canvas::Path::new(|builder| {
builder.move_to(map(stroke[0][0], stroke[0][1]));
for &[x, y] in &stroke[1..] {
builder.line_to(map(x, y));
}
});
frame.stroke(
&path,
canvas::Stroke::default().with_color(ink).with_width(1.35),
);
}
}
let font_label = if self.big_font.trim().is_empty() {
self.font.clone()
} else {
format!("{} + {}", self.font, self.big_font)
};
let mode = if self.annotative {
t!("Paper height")
} else {
t!("Drawing height")
};
frame.fill_text(canvas::Text {
content: format!("{} · {} {:.3}", font_label, mode, self.height),
position: Point::new(10.0, bounds.height - 9.0),
color: ink.scale_alpha(0.65),
size: iced::Pixels(10.0),
align_y: iced::alignment::Vertical::Center,
..Default::default()
});
vec![frame.into_geometry()]
}
}
fn input_row<'a>(
label: Cow<'static, str>,
placeholder: Cow<'static, str>,
value: &'a str,
field: &'static str,
read_only: bool,
) -> Element<'a, Message> {
let control: Element<'a, Message> = if read_only {
container(text(value).size(11)).padding([5, 7]).width(180).into()
} else {
text_input(placeholder.as_ref(), value)
.on_input(move |value| Message::TextStyleEdit { field, value })
.style(field_style)
.size(11)
.width(180)
.into()
};
row![text(label).size(11).style(muted_style).width(150), control]
.spacing(8)
.align_y(iced::Center)
.into()
}
fn toggle<'a>(
label: Cow<'static, str>,
value: bool,
field: &'static str,
read_only: bool,
) -> Element<'a, Message> {
let checkbox = checkbox(value).label(label).size(14).text_size(11);
if read_only {
checkbox.into()
} else {
checkbox
.on_toggle(move |_| Message::TextStyleToggle(field))
.into()
}
}
fn font_list<'a>(
title: Cow<'static, str>,
values: Vec<String>,
selected: &'a str,
system: bool,
read_only: bool,
) -> Element<'a, Message> {
let items: Vec<Element<'a, Message>> = values
.into_iter()
.map(|font| {
let active = selected.eq_ignore_ascii_case(&font);
let button = button(text(font.clone()).size(10))
.style(list_item(active))
.padding([3, 8])
.width(Length::Fill);
if read_only {
button.into()
} else if system {
button
.on_press(Message::TextStyleEdit {
field: "ttf",
value: font,
})
.into()
} else {
button.on_press(Message::TextStyleFontPick(font)).into()
}
})
.collect();
container(column![text(title).size(10).style(muted_style), scrollable(column(items).spacing(1)).height(250)].spacing(5))
.width(Length::FillPortion(1))
.padding(4)
.into()
}
pub fn view_window<'a>(
v: TextStyleView<'a>,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let TextStyleView {
styles,
selected,
current,
font_buf,
width_buf,
oblique_buf,
height_buf,
bigfont_buf,
ttf_buf,
backward,
upside_down,
annotative,
rename_active,
rename_buf,
} = v;
// ── Middle: Font browser ──────────────────────────────────────────────
let font_items: Vec<Element<'_, Message>> = BUILTIN_FONTS
.iter()
.map(|&f| {
let is_sel = font_buf == f;
button(text(f).size(10))
.on_press(Message::TextStyleFontPick(f.to_string()))
.style(list_item(is_sel))
.padding([3, 8])
.width(sizing.width)
.into()
})
.collect();
let font_panel = container(
column![
text(t!("Font File")).size(10).style(muted_style),
container(scrollable(column(font_items).spacing(1)).height(sizing.height))
.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: 3.0.into()
},
..Default::default()
}
})
.width(sizing.width)
.height(sizing.height)
.padding(2),
text_input(t!("font file…").as_ref(), font_buf)
.on_input(|v| Message::TextStyleEdit {
field: "font",
value: v
})
.style(field_style)
.size(11)
.width(sizing.width),
]
.spacing(6)
.height(sizing.height),
)
.width(190)
.height(sizing.height)
.padding([12, 8]);
// Labeled numeric/text field row → TextStyleEdit { field, value }.
fn frow<'a>(
label: impl Into<Cow<'static, str>>,
ph: impl Into<Cow<'static, str>>,
buf: &'a str,
field: &'static str,
) -> Element<'a, Message> {
row![
text(label.into()).size(11).style(muted_style).width(120),
text_input(ph.into().as_ref(), buf)
.on_input(move |v| Message::TextStyleEdit { field, value: v })
.style(field_style)
.size(11)
.width(140),
]
.spacing(6)
.align_y(iced::Center)
.into()
}
// Live preview reflecting the in-progress edits. Effective font follows the
// entity rule: TrueType name wins when set, else the stroke font file.
let eff_font = if !ttf_buf.trim().is_empty() {
ttf_buf
let effective_font = if v.ttf_buf.trim().is_empty() {
v.font_buf
} else {
font_buf
v.ttf_buf
};
let prev_base_wf = width_buf
let width = v
.width_buf
.trim()
.parse::<f32>()
.unwrap_or(1.0)
.abs()
.clamp(0.01, 100.0);
let preview = canvas(TextPreviewCanvas {
font: eff_font.to_string(),
width_factor: if backward { -prev_base_wf } else { prev_base_wf },
oblique: oblique_buf.trim().parse::<f32>().unwrap_or(0.0).to_radians(),
rotation: if upside_down { std::f32::consts::PI } else { 0.0 },
font: effective_font.to_string(),
big_font: v.bigfont_buf.to_string(),
width_factor: if v.backward { -width } else { width },
oblique: v.oblique_buf.trim().parse::<f32>().unwrap_or(0.0).to_radians(),
height: v.height_buf.trim().parse::<f32>().unwrap_or(0.0).max(0.0),
upside_down: v.upside_down,
vertical: v.vertical,
annotative: v.annotative,
})
.width(sizing.width)
.height(Length::Fixed(56.0));
.width(Length::Fill)
.height(112);
// ── Right: Properties ─────────────────────────────────────────────────
let props_panel = container(
column![
text(t!("Properties")).size(11).style(primary_style),
frow(t!("Big Font:"), t!("big-font file…"), bigfont_buf, "bigfont"),
frow(t!("TrueType Font:"), t!("e.g. Arial"), ttf_buf, "ttf"),
frow(
if annotative {
t!("Paper Text Height:")
} else {
t!("Fixed Height:")
},
t!("0 = variable"),
height_buf,
"height",
),
frow(t!("Width Factor:"), "1.0", width_buf, "width"),
frow(t!("Oblique (°):"), "0.0", oblique_buf, "oblique"),
row![
checkbox(backward)
.label(t!("Backward"))
.on_toggle(|_| Message::TextStyleToggle("backward"))
.size(15)
.text_size(11),
checkbox(upside_down)
.label(t!("Upside down"))
.on_toggle(|_| Message::TextStyleToggle("upside_down"))
.size(15)
.text_size(11),
let content: Element<'a, Message> = match v.tab {
0 => {
let built_in = BUILTIN_FONTS.iter().map(|value| (*value).to_string()).collect();
let system = crate::scene::text::sysfont::families().to_vec();
column![
row![
font_list(t!("Stroke fonts"), built_in, v.font_buf, false, v.read_only),
font_list(t!("System fonts"), system, v.ttf_buf, true, v.read_only),
]
.spacing(10),
input_row(t!("Font file"), t!("font file…"), v.font_buf, "font", v.read_only),
input_row(t!("Big font"), t!("big-font file…"), v.bigfont_buf, "bigfont", v.read_only),
input_row(t!("System font"), t!("font family…"), v.ttf_buf, "ttf", v.read_only),
]
.spacing(16),
checkbox(annotative)
.label(t!("Annotative"))
.on_toggle(|_| Message::TextStyleToggle("annotative"))
.size(15)
.text_size(11),
Space::new().height(8),
text(t!("Preview")).size(10).style(muted_style),
container(preview)
.style(|theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.background.base.color)),
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 4.0.into()
},
..Default::default()
}
})
.padding(8)
.width(sizing.width),
.spacing(8)
.into()
}
_ => column![
text(t!("Size")).size(11).style(primary_style),
input_row(
if v.annotative { t!("Paper text height") } else { t!("Fixed height") },
t!("0 = variable"),
v.height_buf,
"height",
v.read_only,
),
text(t!("Effects")).size(11).style(primary_style),
input_row(t!("Width factor"), "1.0".into(), v.width_buf, "width", v.read_only),
input_row(t!("Oblique angle"), "0.0".into(), v.oblique_buf, "oblique", v.read_only),
row![
toggle(t!("Backward"), v.backward, "backward", v.read_only),
toggle(t!("Upside down"), v.upside_down, "upside_down", v.read_only),
toggle(
t!("Vertical"),
v.vertical,
"vertical",
v.read_only || !v.ttf_buf.trim().is_empty(),
),
]
.spacing(18),
toggle(t!("Annotative"), v.annotative, "annotative", v.read_only),
]
.spacing(10)
.height(sizing.height),
)
.width(sizing.width)
.height(sizing.height)
.padding(iced::Padding {
top: 12.0,
right: 12.0,
bottom: 12.0,
left: 8.0,
});
// ── System TrueType fonts (read from the user's machine via fontdb) ──
let ttf_items: Vec<Element<'_, Message>> = crate::scene::text::sysfont::families()
.iter()
.map(|fam| {
let is_sel = ttf_buf.eq_ignore_ascii_case(fam);
button(text(fam).size(10))
.on_press(Message::TextStyleEdit {
field: "ttf",
value: fam.clone(),
})
.style(list_item(is_sel))
.padding([3, 8])
.width(sizing.width)
.into()
})
.collect();
let ttf_panel = container(
column![
text(t!("TrueType (system)")).size(10).style(muted_style),
container(scrollable(column(ttf_items).spacing(1)).height(sizing.height))
.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: 3.0.into()
},
..Default::default()
}
})
.width(sizing.width)
.height(sizing.height)
.padding(2),
text_input(t!("TrueType font…").as_ref(), ttf_buf)
.on_input(|v| Message::TextStyleEdit {
field: "ttf",
value: v
})
.style(field_style)
.size(11)
.width(sizing.width),
]
.spacing(6)
.height(sizing.height),
)
.width(190)
.height(sizing.height)
.padding([12, 8]);
let editor = row![
font_panel,
vsep(sizing.height),
ttf_panel,
vsep(sizing.height),
props_panel
]
.height(sizing.height);
.spacing(9)
.into(),
};
let status = if v.read_only {
t!("Referenced style · Read only")
} else if v.selected.eq_ignore_ascii_case(v.current) {
t!("Current style")
} else if v.in_use {
t!("Used in drawing")
} else {
t!("Available style")
};
let summary = if v.comparison_sections.is_empty() {
t!("No differences").into_owned()
} else {
format!("{}: {}", t!("Different"), v.comparison_sections.join(", "))
};
let editor = crate::ui::style::style_manager::editor_shell(
crate::ui::style::style_manager::EditorShell {
sizing,
selected: v.selected.to_string(),
status,
preview: preview.into(),
comparison: Some(crate::ui::style::style_manager::EditorComparison {
selected: v.compare_name,
options: v.compare_opts,
summary,
on_select: Message::TextStyleDialogCompare,
}),
tabs: vec![
crate::ui::style::style_manager::EditorTab {
label: t!("Fonts"),
active: v.tab == 0,
on_press: Message::TextStyleDialogTab(0),
},
crate::ui::style::style_manager::EditorTab {
label: t!("Size and Effects"),
active: v.tab == 1,
on_press: Message::TextStyleDialogTab(1),
},
],
content,
},
);
crate::ui::style::style_manager::view(crate::ui::style::style_manager::Scaffold {
sizing,
kind: StyleKind::Text,
styles: &styles,
selected,
current: Some(current),
rename_active,
rename_buf,
styles: &v.styles,
selected: v.selected,
current: Some(v.current),
rename_active: v.rename_active,
rename_buf: v.rename_buf,
on_new: Message::TextStyleDialogNew,
on_copy: Message::TextStyleDialogCopy,
on_delete: Message::TextStyleDialogDelete,
on_select: Message::TextStyleDialogSelect,
on_set_current: Message::TextStyleDialogSetCurrent,
on_apply: Message::TextStyleApply,
editor: editor.into(),
read_only: v.read_only,
editor,
})
}