Files
OneCAD-Draft/src/ui/window/attman_dialog.rs
T
ramox81andClaude Opus 5.5 4726c775bf Block Attribute Manager window (BATTMAN / ATTMAN)
BATTMAN and ATTMAN open a manager for the attribute definitions of one
attributed block: block chips with reference counts and a pick button,
a table whose columns are chosen with chips, move up/down, remove, and
an edit card (Attribute / Text / Properties) for the selected row. Apply,
OK and Sync write the list back to the block definition and, when asked,
rebuild the existing references keeping values through tag renames;
each is one undo step. Live changes are taken back on Cancel. Automation
gains attman_dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:10:54 +03:00

1038 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Block Attribute Manager (BATTMAN / ATTMAN): the attribute definitions of
//! one attributed block as a table, with their order, editing, removal and
//! the update of the existing references.
//!
//! Blocks are chips across the top; the columns the table shows are chips
//! under it; the row actions sit in a column beside it; the selected row is
//! edited in a card below (Attribute / Text / Properties) and the settings
//! are switches in the footer.
use codec::entities::AttributeDefinition;
use codec::Handle;
use iced::widget::{
button, column, container, pick_list, row, scrollable, text, text_input, toggler, Space,
};
use iced::{Background, Border, Color, Element, Fill, Length, Theme};
use crate::app::Message;
use crate::modules::draw::draw::attdef::Justify;
use crate::t;
use crate::ui::style::common::muted_style;
use crate::ui::style::form::{button_style, dialog_button, field_style};
use crate::ui::window::pdf_dialogs::{accent_text, card_style, well_style};
static ICON_UP: &[u8] = include_bytes!("../../../assets/icons/ui/up.svg");
static ICON_PICK: &[u8] = include_bytes!("../../../assets/icons/blocks/select_objects.svg");
/// The columns the table can show besides Tag, in the order of their chips.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Column {
Prompt,
Default,
Modes,
Annotative,
Style,
Height,
Layer,
Justify,
Rotation,
WidthFactor,
Oblique,
BoundaryWidth,
Linetype,
Color,
Lineweight,
}
impl Column {
pub const ALL: [Column; 15] = [
Column::Prompt,
Column::Default,
Column::Modes,
Column::Annotative,
Column::Style,
Column::Height,
Column::Layer,
Column::Justify,
Column::Rotation,
Column::WidthFactor,
Column::Oblique,
Column::BoundaryWidth,
Column::Linetype,
Column::Color,
Column::Lineweight,
];
/// The chips shown before "+N".
const FIRST: usize = 7;
pub fn label(self) -> &'static str {
match self {
Column::Prompt => "Prompt",
Column::Default => "Default",
Column::Modes => "Modes",
Column::Annotative => "Annotative",
Column::Style => "Style",
Column::Height => "Height",
Column::Layer => "Layer",
Column::Justify => "Justification",
Column::Rotation => "Rotation",
Column::WidthFactor => "Width factor",
Column::Oblique => "Oblique angle",
Column::BoundaryWidth => "Boundary width",
Column::Linetype => "Linetype",
Column::Color => "Color",
Column::Lineweight => "Lineweight",
}
}
pub fn from_name(name: &str) -> Option<Column> {
let key: String = name
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_lowercase();
Column::ALL.into_iter().find(|c| {
c.label()
.chars()
.filter(|ch| ch.is_ascii_alphanumeric())
.collect::<String>()
.eq_ignore_ascii_case(&key)
})
}
fn bit(self) -> u32 {
1 << (self as u32)
}
fn cell(self, def: &AttributeDefinition) -> String {
let num = |v: f64| crate::ui::window::block_palette::short(v);
match self {
Column::Prompt => def.prompt.clone(),
Column::Default => def.default_value.clone(),
Column::Modes => modes(def),
Column::Annotative => yes_no(def.flags.annotative),
Column::Style => style_name(def),
Column::Height => num(def.height),
Column::Layer => def.common.layer.clone(),
Column::Justify => t!(justify(def).label()).into_owned(),
Column::Rotation => num(def.rotation.to_degrees()),
Column::WidthFactor => num(def.width_factor),
Column::Oblique => num(def.oblique_angle.to_degrees()),
Column::BoundaryWidth => def
.embedded_mtext
.as_ref()
.map_or(String::new(), |m| num(m.rectangle_width)),
Column::Linetype => crate::ui::properties::linetype_display_name(&def.common.linetype),
Column::Color => color_label(def.common.color),
Column::Lineweight => lineweight_label(def.common.line_weight),
}
}
}
/// What the dialog remembers between openings.
#[derive(Debug, Clone, Copy)]
pub struct AttManPrefs {
/// Bit set of [`Column`]s shown.
pub columns: u32,
/// Apply and Sync rebuild the attributes of the existing references.
pub apply_existing: bool,
/// Rows sharing a tag are drawn in the danger colour.
pub emphasize_duplicates: bool,
/// Every edit is written to the drawing at once.
pub live: bool,
}
static PREFS: std::sync::Mutex<AttManPrefs> = std::sync::Mutex::new(AttManPrefs {
columns: (1 << Column::Prompt as u32)
| (1 << Column::Default as u32)
| (1 << Column::Modes as u32)
| (1 << Column::Annotative as u32),
apply_existing: true,
emphasize_duplicates: true,
live: true,
});
pub fn prefs() -> std::sync::MutexGuard<'static, AttManPrefs> {
PREFS.lock().unwrap_or_else(|e| e.into_inner())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EditTab {
#[default]
Attribute,
Text,
Properties,
}
/// One attributed block the dialog can show.
#[derive(Debug, Clone)]
pub struct AttManBlock {
pub name: String,
/// References anywhere in the drawing.
pub found: usize,
/// References in the current space.
pub in_space: usize,
}
/// One attribute definition of the shown block.
#[derive(Debug, Clone)]
pub struct AttManRow {
/// The definition object in the block.
pub handle: Handle,
/// The tag the definition had when the block was loaded, for keeping the
/// references' values when it is renamed.
pub original_tag: String,
/// The working copy.
pub def: AttributeDefinition,
}
/// The edit card's typed fields, kept as text while they are typed.
#[derive(Debug, Clone, Default)]
pub struct Drafts {
pub tag: String,
pub height: String,
pub rotation: String,
pub width: String,
pub oblique: String,
pub boundary: String,
}
pub struct AttManState {
pub blocks: Vec<AttManBlock>,
pub block: usize,
pub rows: Vec<AttManRow>,
/// Definitions removed from the shown block since it was loaded.
pub removed: Vec<Handle>,
pub selected: Option<usize>,
pub tab: EditTab,
/// The edit card is shown.
pub edit_open: bool,
pub drafts: Drafts,
/// The working copy differs from the drawing.
pub dirty: bool,
/// Undo steps the live changes since the last Apply took (one unless
/// something closed the open step).
pub live_steps: usize,
/// The column chips past the first ones are shown.
pub more_columns: bool,
pub styles: Vec<String>,
pub layers: Vec<String>,
pub linetypes: Vec<String>,
pub error: Option<String>,
}
impl AttManState {
pub fn current_block(&self) -> Option<&AttManBlock> {
self.blocks.get(self.block)
}
pub fn selected_row(&self) -> Option<&AttManRow> {
self.selected.and_then(|i| self.rows.get(i))
}
/// Load the drafts from the selected row.
pub fn load_drafts(&mut self) {
let num = |v: f64| crate::ui::window::block_palette::short(v);
self.drafts = match self.selected_row() {
Some(row) => {
let d = &row.def;
Drafts {
tag: d.tag.clone(),
height: num(d.height),
rotation: num(d.rotation.to_degrees()),
width: num(d.width_factor),
oblique: num(d.oblique_angle.to_degrees()),
boundary: d
.embedded_mtext
.as_ref()
.map_or("0".into(), |m| num(m.rectangle_width)),
}
}
None => Drafts::default(),
};
}
/// Tags held by more than one row, upper-cased.
fn duplicate_tags(&self) -> Vec<String> {
let mut seen = std::collections::HashMap::<String, usize>::new();
for row in &self.rows {
*seen.entry(row.def.tag.to_ascii_uppercase()).or_default() += 1;
}
seen.into_iter()
.filter(|(_, n)| *n > 1)
.map(|(tag, _)| tag)
.collect()
}
}
/// One edit in the dialog.
#[derive(Debug, Clone)]
pub enum AttManMsg {
Block(usize),
PickBlock,
Select(usize),
MoveUp,
MoveDown,
ToggleEdit,
Remove,
Tab(EditTab),
Column(Column, bool),
MoreColumns,
Tag(String),
Prompt(String),
Default(String),
/// "invisible", "verify", "preset" or "multiline".
Mode(&'static str, bool),
Style(String),
Justify(Justify),
Height(String),
Rotation(String),
Width(String),
Oblique(String),
Boundary(String),
Annotative(bool),
Backwards(bool),
UpsideDown(bool),
Layer(String),
Linetype(String),
Color(String),
Lineweight(String),
ApplyExisting(bool),
Emphasize(bool),
Live(bool),
Sync,
Apply,
Ok,
Cancel,
Help,
DismissError,
}
fn msg(m: AttManMsg) -> Message {
Message::AttMan(m)
}
// ── Cell text ─────────────────────────────────────────────────────────────
fn yes_no(on: bool) -> String {
if on { t!("Yes") } else { t!("No") }.into_owned()
}
/// Mode letters: I invisible, C constant, V verify, P preset, L locked
/// position, M multiple lines.
pub fn modes(def: &AttributeDefinition) -> String {
let f = &def.flags;
[
(f.invisible, 'I'),
(f.constant, 'C'),
(f.verify, 'V'),
(f.preset, 'P'),
(f.locked_position || def.lock_position, 'L'),
(def.is_multiline, 'M'),
]
.into_iter()
.filter_map(|(on, letter)| on.then_some(letter))
.collect()
}
pub fn justify(def: &AttributeDefinition) -> Justify {
Justify::from_alignment(def.horizontal_alignment, def.vertical_alignment)
}
fn style_name(def: &AttributeDefinition) -> String {
if def.text_style.trim().is_empty() {
"Standard".into()
} else {
def.text_style.clone()
}
}
/// The colours the Properties tab offers.
pub const COLORS: [&str; 9] = [
"ByLayer", "ByBlock", "Red", "Yellow", "Green", "Cyan", "Blue", "Magenta", "White",
];
pub fn color_label(color: codec::types::Color) -> String {
use codec::types::Color as C;
match color {
C::ByLayer => "ByLayer".into(),
C::ByBlock => "ByBlock".into(),
C::Index(n @ 1..=7) => COLORS[n as usize + 1].into(),
C::Index(n) => format!("{n}"),
C::Rgb { r, g, b } => format!("{r},{g},{b}"),
C::None => "None".into(),
}
}
pub fn parse_color(label: &str) -> Option<codec::types::Color> {
use codec::types::Color as C;
match COLORS.iter().position(|c| c.eq_ignore_ascii_case(label.trim())) {
Some(0) => Some(C::ByLayer),
Some(1) => Some(C::ByBlock),
Some(i) => Some(C::Index(i as u8 - 1)),
None => label
.trim()
.parse::<u8>()
.ok()
.filter(|n| (1..=255).contains(n))
.map(C::Index),
}
}
/// The lineweights the Properties tab offers, in 1/100 mm.
const LINEWEIGHTS: [i16; 24] = [
0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158,
200, 211,
];
pub fn lineweight_label(lw: codec::types::LineWeight) -> String {
use codec::types::LineWeight as L;
match lw {
L::ByLayer => "ByLayer".into(),
L::ByBlock => "ByBlock".into(),
L::Default => "Default".into(),
L::Value(v) => format!("{:.2} mm", f64::from(v) / 100.0),
}
}
pub fn parse_lineweight(label: &str) -> Option<codec::types::LineWeight> {
use codec::types::LineWeight as L;
let s = label.trim();
match s.to_ascii_lowercase().as_str() {
"bylayer" => Some(L::ByLayer),
"byblock" => Some(L::ByBlock),
"default" => Some(L::Default),
_ => {
let mm = s.trim_end_matches("mm").trim().parse::<f64>().ok()?;
let v = (mm * 100.0).round() as i16;
LINEWEIGHTS.contains(&v).then_some(L::Value(v))
}
}
}
fn lineweight_options() -> Vec<String> {
["ByLayer", "ByBlock", "Default"]
.into_iter()
.map(str::to_string)
.chain(
LINEWEIGHTS
.iter()
.map(|v| lineweight_label(codec::types::LineWeight::Value(*v))),
)
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct JustifyItem(Justify);
impl std::fmt::Display for JustifyItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&t!(self.0.label()))
}
}
// ── View ──────────────────────────────────────────────────────────────────
fn panel<'a>(content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
container(content)
.padding([10, 12])
.width(Fill)
.style(card_style)
.into()
}
/// A pill: framed in the accent colour while on.
fn pill<'a>(label: String, on: bool, message: Option<Message>) -> Element<'a, Message> {
let b = button(text(label).size(11))
.padding([4, 11])
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let mut style = button_style(false)(theme, status);
style.border = Border {
width: 1.0,
radius: 12.0.into(),
color: if on {
palette.primary.base.color
} else {
palette.background.strong.color
},
};
if on {
style.background = Some(Background::Color(
palette.primary.base.color.scale_alpha(0.16),
));
}
style
});
match message {
Some(m) => b.on_press(m).into(),
None => b.into(),
}
}
fn icon_button<'a>(
icon: &'static [u8],
rotation: f32,
message: Option<Message>,
danger: bool,
) -> Element<'a, Message> {
let enabled = message.is_some();
let svg = iced::widget::svg(crate::ui::icons::themed_handle(icon))
.width(14)
.height(14)
.rotation(iced::Radians(rotation))
.style(move |theme: &Theme, _| iced::widget::svg::Style {
color: Some(if danger && enabled {
theme.palette().danger.base.color
} else if enabled {
theme.palette().background.base.text
} else {
theme.palette().background.base.text.scale_alpha(0.35)
}),
});
let b = button(container(svg).center_x(Fill).center_y(Fill))
.width(30)
.height(28)
.padding(0)
.style(button_style(false));
match message {
Some(m) => b.on_press(m).into(),
None => b.into(),
}
}
fn labeled_switch<'a>(label: String, on: bool, f: fn(bool) -> AttManMsg) -> Element<'a, Message> {
row![
toggler(on).on_toggle(move |v| msg(f(v))).size(16.0),
text(label).size(11),
]
.spacing(6)
.align_y(iced::Center)
.into()
}
fn field_row<'a>(
label: String,
value: &'a str,
on_input: Option<fn(String) -> AttManMsg>,
) -> Element<'a, Message> {
let input: Element<'a, Message> = match on_input {
Some(f) => text_input("", value)
.on_input(move |v| msg(f(v)))
.style(field_style)
.size(11)
.padding([4, 8])
.width(Fill)
.into(),
None => crate::ui::read_only::field(value, 11.0, Fill),
};
row![
text(label)
.size(11)
.style(muted_style)
.width(Length::Fixed(96.0)),
input
]
.spacing(8)
.align_y(iced::Center)
.into()
}
fn choice_row<'a>(
label: String,
selected: String,
options: Vec<String>,
on: Option<fn(String) -> AttManMsg>,
) -> Element<'a, Message> {
let list: Element<'a, Message> = match on {
Some(f) => pick_list(Some(selected), options, |s: &String| s.clone())
.on_select(move |v| msg(f(v)))
.text_size(11)
.padding([3, 8])
.width(Fill)
.into(),
None => crate::ui::read_only::field(&selected, 11.0, Fill),
};
row![
text(label)
.size(11)
.style(muted_style)
.width(Length::Fixed(96.0)),
list
]
.spacing(8)
.align_y(iced::Center)
.into()
}
fn segmented<'a>(current: EditTab) -> Element<'a, Message> {
let tabs = [
(EditTab::Attribute, t!("Attribute").into_owned()),
(EditTab::Text, t!("Text").into_owned()),
(EditTab::Properties, t!("Properties").into_owned()),
];
let buttons = tabs.into_iter().map(|(tab, label)| {
button(text(label).size(11).width(Fill).align_x(iced::Center))
.on_press(msg(AttManMsg::Tab(tab)))
.style(button_style(tab == current))
.padding([5, 6])
.width(Fill)
.into()
});
container(iced::widget::Row::with_children(buttons.collect::<Vec<_>>()).spacing(2))
.padding(2)
.width(Length::Fixed(330.0))
.style(well_style)
.into()
}
fn table<'a>(state: &'a AttManState, columns: &[Column], emphasize: bool) -> Element<'a, Message> {
let duplicates = if emphasize {
state.duplicate_tags()
} else {
Vec::new()
};
let header = {
let mut r = row![text(t!("Tag")).size(11).style(muted_style).width(Fill)];
for c in columns {
r = r.push(text(t!(c.label())).size(11).style(muted_style).width(Fill));
}
container(r.spacing(8)).padding([4, 8])
};
let rows = state.rows.iter().enumerate().map(|(index, entry)| {
let duplicate = duplicates.contains(&entry.def.tag.to_ascii_uppercase());
let chosen = state.selected == Some(index);
let tint = move |theme: &Theme| text::Style {
color: duplicate.then(|| theme.palette().danger.base.color),
};
let mut r = row![text(entry.def.tag.clone()).size(11).style(tint).width(Fill)];
for c in columns {
r = r.push(text(c.cell(&entry.def)).size(11).style(tint).width(Fill));
}
button(r.spacing(8))
.on_press(msg(AttManMsg::Select(index)))
.padding([5, 8])
.width(Fill)
.style(move |theme: &Theme, status| {
let palette = theme.palette();
let mut style = button::text(theme, status);
style.text_color = palette.background.base.text;
style.background = if chosen {
Some(Background::Color(palette.primary.base.color.scale_alpha(0.18)))
} else if matches!(status, button::Status::Hovered) {
Some(Background::Color(palette.background.strong.color))
} else {
None
};
style
})
.into()
});
column![
header,
container(Space::new().height(1))
.width(Fill)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.strong.color)),
..Default::default()
}),
scrollable(column(rows.collect::<Vec<Element<'a, Message>>>()).spacing(1))
.height(Length::Fixed(150.0)),
]
.spacing(2)
.width(Fill)
.into()
}
fn edit_card<'a>(state: &'a AttManState, row_: &'a AttManRow) -> Element<'a, Message> {
let def = &row_.def;
let title = text(format!("{} — {}", def.tag, t!("Edit")).to_uppercase())
.size(10)
.style(accent_text);
let body: Element<'a, Message> = match state.tab {
EditTab::Attribute => {
let constant = def.flags.constant;
let mode = |key: &'static str, label: &str, on: bool| {
pill(
t!(label).into_owned(),
on,
Some(msg(AttManMsg::Mode(key, !on))),
)
};
let fields = column![
field_row(t!("Tag").into_owned(), &state.drafts.tag, Some(AttManMsg::Tag)),
field_row(
t!("Prompt").into_owned(),
&def.prompt,
(!constant).then_some(AttManMsg::Prompt as fn(String) -> AttManMsg),
),
field_row(
t!("Default").into_owned(),
&def.default_value,
Some(AttManMsg::Default),
),
]
.spacing(6)
.width(Fill);
let modes = column![
row![
mode("invisible", "Invisible", def.flags.invisible),
mode("verify", "Verify", def.flags.verify),
]
.spacing(6),
row![
mode("preset", "Preset", def.flags.preset),
mode("multiline", "Multiple lines", def.is_multiline),
]
.spacing(6),
text(t!("The Constant mode cannot be changed in the definition."))
.size(10)
.style(muted_style),
]
.spacing(6)
.width(Length::Fixed(230.0));
row![fields, modes].spacing(18).into()
}
EditTab::Text => {
let justify_list: Element<'a, Message> = if def.is_multiline {
crate::ui::read_only::field(&t!(justify(def).label()), 11.0, Fill)
} else {
pick_list(
Some(JustifyItem(justify(def))),
Justify::ALL.map(JustifyItem).to_vec(),
|j: &JustifyItem| j.to_string(),
)
.on_select(|j: JustifyItem| msg(AttManMsg::Justify(j.0)))
.text_size(11)
.padding([3, 8])
.width(Fill)
.into()
};
let left = column![
choice_row(
t!("Text Style").into_owned(),
style_name(def),
state.styles.clone(),
Some(AttManMsg::Style),
),
row![
text(t!("Justification"))
.size(11)
.style(muted_style)
.width(Length::Fixed(96.0)),
justify_list
]
.spacing(8)
.align_y(iced::Center),
field_row(
t!("Height").into_owned(),
&state.drafts.height,
Some(AttManMsg::Height)
),
field_row(
t!("Rotation").into_owned(),
&state.drafts.rotation,
Some(AttManMsg::Rotation)
),
]
.spacing(6)
.width(Fill);
let right = column![
field_row(
t!("Width factor").into_owned(),
&state.drafts.width,
Some(AttManMsg::Width)
),
field_row(
t!("Oblique angle").into_owned(),
&state.drafts.oblique,
Some(AttManMsg::Oblique)
),
field_row(
t!("Boundary width").into_owned(),
&state.drafts.boundary,
def.is_multiline
.then_some(AttManMsg::Boundary as fn(String) -> AttManMsg),
),
row![
pill(
t!("Annotative").into_owned(),
def.flags.annotative,
Some(msg(AttManMsg::Annotative(!def.flags.annotative))),
),
pill(
t!("Backwards").into_owned(),
def.text_generation_flags & 0x2 != 0,
Some(msg(AttManMsg::Backwards(
def.text_generation_flags & 0x2 == 0
))),
),
pill(
t!("Upside down").into_owned(),
def.text_generation_flags & 0x4 != 0,
Some(msg(AttManMsg::UpsideDown(
def.text_generation_flags & 0x4 == 0
))),
),
]
.spacing(6),
]
.spacing(6)
.width(Fill);
row![left, right].spacing(18).into()
}
EditTab::Properties => {
let mut linetypes = vec!["ByLayer".to_string(), "ByBlock".to_string()];
linetypes.extend(state.linetypes.iter().cloned());
let linetype = if def.common.linetype.is_empty() {
"ByLayer".to_string()
} else {
def.common.linetype.clone()
};
row![
column![
choice_row(
t!("Layer").into_owned(),
def.common.layer.clone(),
state.layers.clone(),
Some(AttManMsg::Layer),
),
choice_row(
t!("Linetype").into_owned(),
linetype,
linetypes,
Some(AttManMsg::Linetype),
),
]
.spacing(6)
.width(Fill),
column![
choice_row(
t!("Color").into_owned(),
color_label(def.common.color),
COLORS.iter().map(|c| c.to_string()).collect(),
Some(AttManMsg::Color),
),
choice_row(
t!("Lineweight").into_owned(),
lineweight_label(def.common.line_weight),
lineweight_options(),
Some(AttManMsg::Lineweight),
),
]
.spacing(6)
.width(Fill),
]
.spacing(18)
.into()
}
};
let live = prefs().live;
panel(
column![
title,
segmented(state.tab),
body,
labeled_switch(
t!("Show changes in the drawing as they are made").into_owned(),
live,
AttManMsg::Live
),
]
.spacing(10),
)
}
pub fn view<'a>(
state: &'a AttManState,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let prefs = *prefs();
// ── Blocks ──
let mut chips = row![icon_button(
ICON_PICK,
0.0,
Some(msg(AttManMsg::PickBlock)),
false
)]
.spacing(6)
.align_y(iced::Center);
for (index, block) in state.blocks.iter().enumerate() {
let on = index == state.block;
let label = if on {
format!(
"{} · {}",
block.name,
crate::tf!("{} references", block.found)
)
} else {
block.name.clone()
};
chips = chips.push(pill(label, on, Some(msg(AttManMsg::Block(index)))));
}
let counts = state.current_block().map_or(String::new(), |b| {
crate::tf!(
"In drawing: {} · in model space: {}",
b.found,
b.in_space
)
.into_owned()
});
let blocks_card = panel(
row![
scrollable(chips).direction(scrollable::Direction::Horizontal(
scrollable::Scrollbar::new().width(3).scroller_width(3)
)),
Space::new().width(Fill),
text(counts).size(10).style(muted_style),
]
.spacing(10)
.align_y(iced::Center),
);
// ── Table ──
let columns: Vec<Column> = Column::ALL
.into_iter()
.filter(|c| prefs.columns & c.bit() != 0)
.collect();
let mut column_chips = row![text(t!("Columns:")).size(11).style(muted_style)]
.spacing(6)
.align_y(iced::Center);
let shown = if state.more_columns {
Column::ALL.len()
} else {
Column::FIRST
};
for c in &Column::ALL[..shown] {
let on = prefs.columns & c.bit() != 0;
column_chips = column_chips.push(pill(
t!(c.label()).into_owned(),
on,
Some(msg(AttManMsg::Column(*c, !on))),
));
}
column_chips = column_chips.push(pill(
if state.more_columns {
"−".to_string()
} else {
format!("+{}", Column::ALL.len() - Column::FIRST)
},
false,
Some(msg(AttManMsg::MoreColumns)),
));
let count = state.rows.len();
let at = state.selected;
let actions = column![
icon_button(
ICON_UP,
0.0,
at.filter(|i| *i > 0).map(|_| msg(AttManMsg::MoveUp)),
false
),
icon_button(
ICON_UP,
std::f32::consts::PI,
at.filter(|i| i + 1 < count).map(|_| msg(AttManMsg::MoveDown)),
false
),
icon_button(
crate::ui::icons::MORE,
0.0,
at.map(|_| msg(AttManMsg::ToggleEdit)),
false
),
// The last attribute of a block cannot be removed.
icon_button(
crate::ui::icons::CLOSE,
0.0,
at.filter(|_| count > 1).map(|_| msg(AttManMsg::Remove)),
true
),
]
.spacing(6);
let table_card = panel(
row![
column![
table(state, &columns, prefs.emphasize_duplicates),
scrollable(column_chips).direction(scrollable::Direction::Horizontal(
scrollable::Scrollbar::new().width(3).scroller_width(3)
)),
]
.spacing(10)
.width(Fill),
actions,
]
.spacing(10),
);
let mut content = column![blocks_card, table_card].spacing(10);
if state.edit_open {
if let Some(selected) = state.selected_row() {
content = content.push(edit_card(state, selected));
}
}
if let Some(error) = &state.error {
content = content.push(
container(
row![
crate::ui::icons::semantic(
include_bytes!("../../../assets/icons/ui/warning_triangle.svg"),
15.0
),
text(error.clone()).size(11).width(Fill),
button(text("×").size(12))
.on_press(msg(AttManMsg::DismissError))
.style(button_style(false))
.padding([1, 6]),
]
.spacing(8)
.align_y(iced::Center),
)
.padding([6, 10])
.width(Fill)
.style(|theme: &Theme| {
let danger = theme.palette().danger.base.color;
container::Style {
background: Some(Background::Color(danger.scale_alpha(0.16))),
border: Border {
width: 1.0,
radius: 6.0.into(),
color: danger,
},
..Default::default()
}
}),
);
}
let footer = row![
button(text("?").size(12))
.on_press(msg(AttManMsg::Help))
.style(button_style(false))
.padding([3, 10]),
labeled_switch(
t!("Apply changes to existing references").into_owned(),
prefs.apply_existing,
AttManMsg::ApplyExisting
),
labeled_switch(
t!("Emphasize duplicate tags").into_owned(),
prefs.emphasize_duplicates,
AttManMsg::Emphasize
),
Space::new().width(Fill),
dialog_button(t!("Sync"), msg(AttManMsg::Sync), false),
dialog_button(t!("Apply"), msg(AttManMsg::Apply), false),
dialog_button(t!("OK"), msg(AttManMsg::Ok), true),
]
.spacing(12)
.align_y(iced::Center);
container(content.push(footer))
.padding([10, 14])
.width(sizing.width)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::TRANSPARENT)),
..Default::default()
})
.into()
}