feat: TextStyle font browser (STYLE DIALOG command)

- STYLE DIALOG / TEXTSTYLE DIALOG opens the Text Style Font Browser
- Three-panel dialog: style list | built-in CXF font list | properties
- Properties panel: font file text field, width factor, oblique angle
- Apply button commits edits; Set Current sets the active text style
- Built-in font picker (20 CXF fonts from assets/fonts/) auto-applies
  font to the selected style on click
- Preview pane shows "AaBbCc 0123" sample text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-04-06 15:01:25 +03:00
commit 3d1f355a5c
4 changed files with 380 additions and 1 deletions

View file

@ -2339,6 +2339,9 @@ impl H7CAD {
let parts: Vec<&str> = rest.splitn(3, ' ').collect();
let sub = parts.get(0).map(|s| s.to_uppercase()).unwrap_or_default();
match sub.as_str() {
"DIALOG" | "UI" => {
return Task::done(Message::TextStyleDialogOpen);
}
"" | "LIST" | "?" => {
let styles: Vec<String> = self.tabs[i].scene.document
.text_styles.iter()

View file

@ -98,6 +98,16 @@ pub(super) struct H7CAD {
tablestyle_open: bool,
tablestyle_selected: String,
// ── TextStyle Font Browser ────────────────────────────────────────────
textstyle_open: bool,
textstyle_selected: String,
/// Edit buffer for font file name.
textstyle_font: String,
/// Edit buffer for width factor.
textstyle_width: String,
/// Edit buffer for oblique angle (degrees).
textstyle_oblique: String,
// ── DimStyle Dialog ───────────────────────────────────────────────────
dimstyle_open: bool,
/// Name of the style currently shown in the dialog.
@ -343,6 +353,19 @@ pub enum Message {
PlotStyleLoaded(Option<crate::io::plot_style::PlotStyleTable>),
/// Clear the active plot style table.
PlotStyleClear,
// ── TextStyle Font Browser ────────────────────────────────────────────
TextStyleDialogOpen,
TextStyleDialogClose,
TextStyleDialogSelect(String),
TextStyleDialogSetCurrent,
TextStyleDialogNew,
TextStyleDialogDelete,
/// Edit a string field (FontFile / Width / Oblique).
TextStyleEdit { field: &'static str, value: String },
/// Commit edits to the selected text style.
TextStyleApply,
/// Select a font from the built-in font list.
TextStyleFontPick(String),
// ── TableStyle Dialog ─────────────────────────────────────────────────
TableStyleDialogOpen,
TableStyleDialogClose,
@ -417,6 +440,12 @@ impl H7CAD {
page_setup_scale: "Fit".to_string(),
// Plot style
active_plot_style: None,
// TextStyle font browser
textstyle_open: false,
textstyle_selected: "Standard".to_string(),
textstyle_font: String::new(),
textstyle_width: "1.0".to_string(),
textstyle_oblique: "0.0".to_string(),
// TableStyle dialog
tablestyle_open: false,
tablestyle_selected: "Standard".to_string(),

View file

@ -2126,6 +2126,122 @@ impl H7CAD {
Task::none()
}
// ── TextStyle Font Browser ────────────────────────────────────────
Message::TextStyleDialogOpen => {
let i = self.active_tab;
let cur = self.tabs[i].scene.document.header.current_text_style_name.clone();
let exists = self.tabs[i].scene.document.text_styles.get(&cur).is_some();
self.textstyle_selected = if exists {
cur
} else {
self.tabs[i].scene.document.text_styles
.iter().next().map(|s| s.name.clone())
.unwrap_or_else(|| "Standard".to_string())
};
self.load_textstyle_bufs(i);
self.textstyle_open = true;
Task::none()
}
Message::TextStyleDialogClose => {
self.textstyle_open = false;
Task::none()
}
Message::TextStyleDialogSelect(name) => {
let i = self.active_tab;
self.textstyle_selected = name;
self.load_textstyle_bufs(i);
Task::none()
}
Message::TextStyleDialogSetCurrent => {
let i = self.active_tab;
let name = self.textstyle_selected.clone();
if self.tabs[i].scene.document.text_styles.get(&name).is_some() {
self.push_undo_snapshot(i, "STYLE SET");
self.tabs[i].scene.document.header.current_text_style_name = name.clone();
self.tabs[i].dirty = true;
self.command_line.push_output(&format!("Current text style: {}", name));
}
Task::none()
}
Message::TextStyleDialogNew => {
let i = self.active_tab;
let doc = &self.tabs[i].scene.document;
let mut n = 1u32;
let new_name = loop {
let candidate = format!("Style{}", n);
if !doc.text_styles.contains(&candidate) { break candidate; }
n += 1;
};
self.push_undo_snapshot(i, "STYLE NEW");
let style = acadrust::tables::TextStyle::new(&new_name);
let _ = self.tabs[i].scene.document.text_styles.add(style);
self.textstyle_selected = new_name.clone();
self.textstyle_font = String::new();
self.textstyle_width = "1.0".to_string();
self.textstyle_oblique = "0.0".to_string();
self.tabs[i].dirty = true;
Task::none()
}
Message::TextStyleDialogDelete => {
let i = self.active_tab;
let name = self.textstyle_selected.clone();
if name.eq_ignore_ascii_case("Standard") {
self.command_line.push_error("Cannot delete the Standard text style.");
return Task::none();
}
self.push_undo_snapshot(i, "STYLE DEL");
self.tabs[i].scene.document.text_styles.remove(&name);
self.textstyle_selected = self.tabs[i].scene.document.text_styles
.iter().next().map(|s| s.name.clone())
.unwrap_or_else(|| "Standard".to_string());
self.load_textstyle_bufs(i);
self.tabs[i].dirty = true;
Task::none()
}
Message::TextStyleEdit { field, value } => {
match field {
"font" => self.textstyle_font = value,
"width" => self.textstyle_width = value,
"oblique" => self.textstyle_oblique = value,
_ => {}
}
Task::none()
}
Message::TextStyleApply => {
let i = self.active_tab;
let name = self.textstyle_selected.clone();
if self.tabs[i].scene.document.text_styles.get(&name).is_some() {
self.push_undo_snapshot(i, "STYLE EDIT");
let font = self.textstyle_font.clone();
let width_str = self.textstyle_width.clone();
let oblique_str = self.textstyle_oblique.clone();
if let Some(s) = self.tabs[i].scene.document.text_styles.get_mut(&name) {
s.font_file = font;
if let Ok(w) = width_str.trim().parse::<f64>() {
s.width_factor = w;
}
if let Ok(a) = oblique_str.trim().parse::<f64>() {
s.oblique_angle = a.to_radians();
}
}
self.tabs[i].dirty = true;
}
Task::none()
}
Message::TextStyleFontPick(font_file) => {
let i = self.active_tab;
self.textstyle_font = font_file.clone();
let name = self.textstyle_selected.clone();
if self.tabs[i].scene.document.text_styles.get(&name).is_some() {
self.push_undo_snapshot(i, "STYLE FONT");
if let Some(s) = self.tabs[i].scene.document.text_styles.get_mut(&name) {
s.font_file = font_file;
}
self.tabs[i].dirty = true;
}
Task::none()
}
// ── TableStyle Dialog ─────────────────────────────────────────────
Message::TableStyleDialogOpen => {
use acadrust::objects::ObjectType;
@ -2465,6 +2581,16 @@ impl H7CAD {
_ => {}
}
}
/// Populate edit buffers from the currently selected text style.
fn load_textstyle_bufs(&mut self, tab: usize) {
let doc = &self.tabs[tab].scene.document;
if let Some(s) = doc.text_styles.get(&self.textstyle_selected) {
self.textstyle_font = s.font_file.clone();
self.textstyle_width = format!("{:.4}", s.width_factor);
self.textstyle_oblique = format!("{:.2}", s.oblique_angle.to_degrees());
}
}
}
/// Parse a scale string like "1:50" or "2:1" into (numerator, denominator).

View file

@ -227,6 +227,15 @@ impl H7CAD {
iced::widget::Space::new().width(0).height(0).into()
};
let textstyle_layer: Element<'_, Message> = if self.textstyle_open {
let tab = &self.tabs[self.active_tab];
let styles: Vec<String> = tab.scene.document.text_styles
.iter().map(|s| s.name.clone()).collect();
textstyle_overlay(styles, &self.textstyle_selected, &self.textstyle_font, &self.textstyle_width, &self.textstyle_oblique)
} else {
iced::widget::Space::new().width(0).height(0).into()
};
let tablestyle_layer: Element<'_, Message> = if self.tablestyle_open {
use acadrust::objects::ObjectType;
let tab = &self.tabs[self.active_tab];
@ -271,7 +280,7 @@ impl H7CAD {
iced::widget::Space::new().width(0).height(0).into()
};
stack![main_ui, self.app_menu.view(), snap_layer, dropdown_layer, layout_ctx_layer, page_setup_layer, tablestyle_layer, mlstyle_layer, dimstyle_layer].into()
stack![main_ui, self.app_menu.view(), snap_layer, dropdown_layer, layout_ctx_layer, page_setup_layer, textstyle_layer, tablestyle_layer, mlstyle_layer, dimstyle_layer].into()
}
pub fn subscription(&self) -> Subscription<Message> {
@ -1052,6 +1061,218 @@ fn dimstyle_overlay<'a>(
stack![catcher, positioned].into()
}
// ── TextStyle Font Browser overlay ─────────────────────────────────────────
/// Built-in CXF font file names (relative to assets/fonts/).
const BUILTIN_FONTS: &[&str] = &[
"CourierCad.cxf", "Cursive.cxf", "GothGBT.cxf", "GothGRT.cxf", "GothITT.cxf",
"GreekC.cxf", "GreekS.cxf", "ItalicC.cxf", "ItalicT.cxf",
"RomanC.cxf", "RomanD.cxf", "RomanS.cxf", "RomanT.cxf",
"SansND.cxf", "SansNS.cxf", "ScriptC.cxf", "ScriptS.cxf",
"Standard.cxf", "Unicode.cxf", "SymbolCad.cxf",
];
fn textstyle_overlay<'a>(
styles: Vec<String>,
selected: &'a str,
font_buf: &'a str,
width_buf: &'a str,
oblique_buf: &'a str,
) -> Element<'a, Message> {
use iced::Length::Shrink;
const PANEL_BG: Color = Color { r: 0.15, g: 0.15, b: 0.15, a: 1.0 };
const BORDER: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0 };
const TEXT_COL: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0 };
const DIM_COL: Color = Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 };
const ACCENT: Color = Color { r: 0.25, g: 0.50, b: 0.85, a: 1.0 };
const ACTIVE_BG: Color = Color { r: 0.20, g: 0.40, b: 0.70, a: 1.0 };
let field_style = |_: &Theme, _: iced::widget::text_input::Status| iced::widget::text_input::Style {
background: Background::Color(Color { r: 0.10, g: 0.10, b: 0.10, a: 1.0 }),
border: Border { color: BORDER, width: 1.0, radius: 3.0.into() },
icon: TEXT_COL, placeholder: DIM_COL, value: TEXT_COL, selection: ACCENT,
};
let btn_style = |accent: bool| move |_: &Theme, status: button::Status| button::Style {
background: Some(Background::Color(match (accent, status) {
(true, button::Status::Hovered | button::Status::Pressed) =>
Color { r: 0.20, g: 0.42, b: 0.72, a: 1.0 },
(false, button::Status::Hovered | button::Status::Pressed) =>
Color { r: 0.28, g: 0.28, b: 0.28, a: 1.0 },
(true, _) => ACCENT,
_ => Color { r: 0.22, g: 0.22, b: 0.22, a: 1.0 },
})),
text_color: TEXT_COL,
border: Border { color: BORDER, width: 1.0, radius: 4.0.into() },
..Default::default()
};
// Left: style list.
let style_items: Vec<Element<'_, Message>> = styles.iter().map(|name| {
let is_sel = name.as_str() == selected;
button(text(name.clone()).size(11).color(TEXT_COL))
.on_press(Message::TextStyleDialogSelect(name.clone()))
.style(move |_: &Theme, st| button::Style {
background: Some(Background::Color(match (is_sel, st) {
(true, _) => ACTIVE_BG,
(false, button::Status::Hovered | button::Status::Pressed) =>
Color { r: 0.26, g: 0.26, b: 0.26, a: 1.0 },
_ => Color::TRANSPARENT,
})),
text_color: TEXT_COL,
..Default::default()
})
.padding([3, 8])
.width(Fill)
.into()
}).collect();
let style_panel = container(
column(style_items).spacing(2)
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color { r: 0.12, g: 0.12, b: 0.12, a: 1.0 })),
border: Border { color: BORDER, width: 1.0, radius: 4.0.into() },
..Default::default()
})
.padding(4)
.width(150)
.height(280);
// Middle: font file list (built-in CXF fonts).
let font_items: Vec<Element<'_, Message>> = BUILTIN_FONTS.iter().map(|&f| {
let is_sel = font_buf == f;
button(text(f).size(10).color(TEXT_COL))
.on_press(Message::TextStyleFontPick(f.to_string()))
.style(move |_: &Theme, st| button::Style {
background: Some(Background::Color(match (is_sel, st) {
(true, _) => ACTIVE_BG,
(false, button::Status::Hovered | button::Status::Pressed) =>
Color { r: 0.26, g: 0.26, b: 0.26, a: 1.0 },
_ => Color::TRANSPARENT,
})),
text_color: TEXT_COL,
..Default::default()
})
.padding([3, 8])
.width(Fill)
.into()
}).collect();
let font_panel = column![
text("Font File:").size(11).color(DIM_COL),
container(
iced::widget::scrollable(column(font_items).spacing(1))
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color { r: 0.12, g: 0.12, b: 0.12, a: 1.0 })),
border: Border { color: BORDER, width: 1.0, radius: 4.0.into() },
..Default::default()
})
.padding(4)
.width(180)
.height(160),
text_input("font file…", font_buf)
.on_input(|v| Message::TextStyleEdit { field: "font", value: v })
.style(field_style)
.size(11)
.width(180),
]
.spacing(4);
// Right: properties + preview.
let props = column![
text("Properties").size(12).color(ACCENT),
row![
text("Width Factor:").size(11).color(DIM_COL).width(110),
text_input("1.0", width_buf)
.on_input(|v| Message::TextStyleEdit { field: "width", value: v })
.style(field_style)
.size(11)
.width(80),
].spacing(6).align_y(iced::Center),
row![
text("Oblique (°):").size(11).color(DIM_COL).width(110),
text_input("0.0", oblique_buf)
.on_input(|v| Message::TextStyleEdit { field: "oblique", value: v })
.style(field_style)
.size(11)
.width(80),
].spacing(6).align_y(iced::Center),
Space::new().height(8),
text("Preview:").size(11).color(DIM_COL),
container(
text("AaBbCc 0123").size(20).color(TEXT_COL)
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color { r: 0.10, g: 0.10, b: 0.10, a: 1.0 })),
border: Border { color: BORDER, width: 1.0, radius: 4.0.into() },
..Default::default()
})
.padding(10)
.width(Fill),
Space::new().height(Fill),
row![
button(text("Apply").size(11))
.on_press(Message::TextStyleApply)
.style(btn_style(true))
.padding([5, 10]),
button(text("Set Current").size(11))
.on_press(Message::TextStyleDialogSetCurrent)
.style(btn_style(false))
.padding([5, 10]),
].spacing(6),
]
.spacing(8)
.width(220);
let panel = container(
column![
row![
text("Text Style Font Browser").size(13).color(TEXT_COL),
Space::new().width(Fill),
row![
button(text("New").size(11))
.on_press(Message::TextStyleDialogNew)
.style(btn_style(false))
.padding([3, 8]),
button(text("Delete").size(11))
.on_press(Message::TextStyleDialogDelete)
.style(btn_style(false))
.padding([3, 8]),
button(text("").size(12).color(DIM_COL))
.on_press(Message::TextStyleDialogClose)
.style(|_: &Theme, _| button::Style {
background: Some(Background::Color(Color::TRANSPARENT)),
text_color: DIM_COL,
..Default::default()
})
.padding([2, 6]),
].spacing(4),
].align_y(iced::Center),
row![style_panel, font_panel, props].spacing(12).align_y(iced::Top),
].spacing(10).padding(16)
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(PANEL_BG)),
border: Border { color: BORDER, width: 1.0, radius: 6.0.into() },
..Default::default()
})
.width(Shrink);
let catcher = mouse_area(
container(iced::widget::Space::new().width(Fill).height(Fill))
).on_press(Message::TextStyleDialogClose);
let positioned = container(panel)
.width(Fill).height(Fill)
.align_x(iced::Alignment::Center)
.align_y(iced::Alignment::Center);
stack![catcher, positioned].into()
}
// ── TableStyle Dialog overlay ───────────────────────────────────────────────
fn tablestyle_overlay<'a>(