feat(block-editor): add functional Visibility ribbon controls

Add the current-state selector, Visibility States dialog and mode/show/hide
controls to the contextual ribbon, using shared icons, tooltips, modal UI
and adaptive flyouts. Disable the controls until a Visibility parameter exists.

Stage state creation, rename, deletion, current-state changes and reordering
until OK. Support copied, show-all and hide-all new states in one undo step,
preserve native graph metadata, and restore current-state identity on undo/redo.

Implement nonsaved BVMODE and display-only reveal during BVSHOW selection.
Cancelling selection leaves saved visibility flags and history unchanged.
Keep the existing command-line state workflows available.

Validation: 6 targeted tests; 277 block, 31 app dock, 35 ribbon and 12 history
checks passed (overlapping filters). All 17 local DWG fixtures passed in-memory
DWG/DXF preservation checks with unchanged original checksums.
This commit is contained in:
Karim Jerbi committed 2026-10-15 15:08:21 +01:00
1 parent 308990e672
commit daa26007f9
24 files changed
+1341 -34

No files matched your search

+11 -1
View File
@@ -26,6 +26,7 @@ pub(crate) use draw_panel::tools as panel_tools;
mod color_dropdown;
mod context_tools;
mod modify_panel;
mod visibility_panel;
pub use context_tools::{pdf_underlay_tools, point_cloud_tools, xref_tools, UnderlayContext};
use widgets::{StyleContext, *};
pub(crate) use widgets::{REDO_HISTORY_ID, UNDO_HISTORY_ID};
@@ -630,6 +631,7 @@ impl Ribbon {
undo_count: usize,
redo_count: usize,
show_block_palette: bool,
visibility: Option<crate::ui::window::block_visibility::RibbonContext>,
) -> Element<'_, Message> {
// ── Quick-access file commands + undo/redo, one merged flow ────────
let lead = iced::widget::Row::with_children(vec![
@@ -825,7 +827,15 @@ impl Ribbon {
// button. See `CollapsePanels`.
let ts = self.toggle_state(show_block_palette);
let panels: Vec<Panel<'_>> = groups
.map(|g| self.panel(g, ts, &style_ctx, &|_| Vec::new()))
.map(|g| {
if self.modules[effective_active].id() == "block_editor"
&& g.title == "Visibility"
{
visibility_panel::panel(visibility.clone())
} else {
self.panel(g, ts, &style_ctx, &|_| Vec::new())
}
})
.collect();
CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H)
.report_height(self.tool_bar_h.clone())
+165
View File
@@ -0,0 +1,165 @@
//! The contextual visibility controls share the ribbon's buttons, tips and collapse layout.
use iced::widget::{button, column, container, pick_list, row, text, tooltip};
use iced::{Element, Fill, Theme};
use super::collapse::Panel;
use super::widgets::{make_icon, tool_btn_style, LARGE_ICON, LARGE_W, SMALL_ICON, TOOL_BAR_H};
use crate::app::Message;
use crate::modules::IconKind;
use crate::ui::window::block_visibility::{Action, RibbonContext};
use crate::ui::ROW_H;
fn tool(
id: &'static str,
label: &'static str,
icon: &'static [u8],
large: bool,
active: bool,
message: Option<Message>,
) -> Element<'static, Message> {
let enabled = message.is_some();
let size = if large { LARGE_ICON } else { SMALL_ICON };
let image = if enabled {
crate::ui::icons::semantic(icon, size)
} else {
crate::ui::icons::semantic_disabled(icon, size)
};
let face: Element<'static, Message> = if large {
column![
image,
text(crate::t!(label).into_owned())
.size(10)
.align_x(iced::Center)
]
.align_x(iced::Center)
.spacing(3)
.into()
} else {
image
};
let mut control = button(face)
.on_press_maybe(message)
.style(move |theme: &Theme, status| {
let mut style = tool_btn_style(theme, active, status);
if !enabled {
style.text_color = style.text_color.scale_alpha(0.42);
style.background = None;
}
style
})
.padding([3, 5]);
control = if large {
control.width(LARGE_W + 8.0).height(3.0 * ROW_H)
} else {
control.width(ROW_H + 4.0).height(ROW_H)
};
tooltip(
super::keytips::anchor(id, container(control).id(id)),
super::tooltip_content(crate::t!(label).into_owned()),
iced::widget::tooltip::Position::Right,
)
.gap(6)
.delay(std::time::Duration::from_millis(400))
.style(super::tooltip_style)
.into()
}
fn view(context: Option<RibbonContext>, compact: bool) -> Element<'static, Message> {
let target = context.as_ref().map(|context| context.target);
let mode = context.as_ref().is_some_and(|context| context.mode);
let manager = tool(
"BVSTATE_MANAGER",
"Visibility States",
include_bytes!("../../../assets/icons/ui/list.svg"),
!compact,
false,
target.map(|target| Message::BlockVisibility(Action::Open(target))),
);
let toggles = row![
tool(
"BVMODE_TOGGLE",
"Visibility Mode",
include_bytes!("../../../assets/icons/constrain/show_all.svg"),
false,
mode,
target.map(|target| Message::BlockVisibility(Action::Mode(target)))
),
tool(
"BVSHOW",
"Make Visible",
include_bytes!("../../../assets/icons/constrain/show.svg"),
false,
false,
target.map(|_| Message::Command("BVSHOW".into()))
),
tool(
"BVHIDE",
"Make Invisible",
include_bytes!("../../../assets/icons/constrain/hide_all.svg"),
false,
false,
target.map(|_| Message::Command("BVHIDE".into()))
),
]
.spacing(2);
let names = context
.as_ref()
.map(|context| context.names.clone())
.unwrap_or_default();
let current = context.as_ref().and_then(|context| context.current.clone());
let mut selector = pick_list(current, names, |name: &String| name.clone())
.placeholder(crate::t!("Visibility state").into_owned())
.text_size(11)
.padding([3, 6])
.width(Fill);
if let Some(target) = target {
selector =
selector.on_select(move |name| Message::BlockVisibility(Action::Current(target, name)));
}
let selector = container(selector).id("BVSTATE_CURRENT").width(Fill);
let controls: Element<'static, Message> = if compact {
column![row![manager, toggles].spacing(3), selector]
.spacing(4)
.width(160)
.height(Fill)
.into()
} else {
row![manager, column![toggles, selector].spacing(6).width(170)]
.spacing(4)
.height(Fill)
.into()
};
column![controls, text(crate::t!("Visibility").into_owned()).size(9)]
.align_x(iced::Center)
.padding([3, 4])
.height(TOOL_BAR_H)
.into()
}
fn collapsed(tight: bool) -> Element<'static, Message> {
let icon = make_icon(
IconKind::Svg(include_bytes!("../../../assets/icons/ui/list.svg")),
if tight { SMALL_ICON } else { LARGE_ICON },
);
button(
column![icon, text(crate::t!("Visibility ▾").into_owned()).size(10)]
.align_x(iced::Center)
.spacing(4),
)
.on_press(Message::ToggleRibbonPanel("Visibility".into()))
.style(|theme, status| tool_btn_style(theme, false, status))
.padding([4, 6])
.into()
}
pub(super) fn panel(context: Option<RibbonContext>) -> Panel<'static> {
Panel {
id: "Visibility".into(),
elements: [
view(context.clone(), false),
view(context, true),
collapsed(false),
collapsed(true),
],
}
}
+203
View File
@@ -0,0 +1,203 @@
use codec::objects::BlockVisibilityParameter;
use codec::Handle;
use iced::widget::{
button, column, container, pick_list, row, scrollable, text, text_input, Space,
};
use iced::{Element, Fill};
use crate::app::Message;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Target {
pub document: u64,
pub definition: Handle,
pub parameter: Handle,
}
#[derive(Debug, Clone)]
pub struct RibbonContext {
pub target: Target,
pub names: Vec<String>,
pub current: Option<String>,
pub mode: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NewVisibility {
Keep,
Show,
Hide,
}
impl std::fmt::Display for NewVisibility {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(
crate::t!(match self {
Self::Keep => "Keep current visibility",
Self::Show => "Show all objects",
Self::Hide => "Hide all objects",
})
.as_ref(),
)
}
}
#[derive(Debug, Clone)]
pub enum Action {
Open(Target),
Current(Target, String),
Mode(Target),
Select(usize),
Name(String),
NewVisibility(NewVisibility),
New,
Rename,
Delete,
SetCurrent,
Move(bool),
Apply,
}
pub(crate) struct State {
pub target: Target,
pub original: BlockVisibilityParameter,
pub edited: BlockVisibilityParameter,
pub current: usize,
pub selected: usize,
pub name: String,
pub new_visibility: NewVisibility,
pub error: Option<String>,
/// A dialog cannot overwrite changes made by another entry point.
pub epoch: u64,
}
impl State {
pub fn select(&mut self, index: usize) {
if let Some(state) = self.edited.states.get(index) {
self.selected = index;
self.name = state.name.clone();
self.error = None;
}
}
pub fn name_error(&self, renaming: bool) -> Option<String> {
if self.name.trim().is_empty() || self.name.chars().any(char::is_control) {
return Some(
crate::t!("Enter a nonempty state name without control characters.").into_owned(),
);
}
self.edited
.states
.iter()
.enumerate()
.any(|(index, state)| {
(!renaming || index != self.selected)
&& state.name.eq_ignore_ascii_case(self.name.trim())
})
.then(|| crate::t!("A visibility state with that name already exists.").into_owned())
}
}
pub(crate) fn view(state: &State, sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> {
let send = |action| Message::BlockVisibility(action);
let mut states = column![].spacing(2);
for (index, item) in state.edited.states.iter().enumerate() {
let label = if index == state.current {
crate::tf!("{} (current)", item.name).into_owned()
} else {
item.name.clone()
};
states = states.push(
button(text(label).size(13))
.width(Fill)
.on_press(send(Action::Select(index)))
.style(if index == state.selected {
button::primary
} else {
button::text
}),
);
}
let control = |label: &'static str, action, enabled: bool| {
button(text(crate::t!(label).into_owned()).size(13))
.width(Fill)
.on_press_maybe(enabled.then_some(send(action)))
};
container(
column![
row![
container(scrollable(states))
.width(Fill)
.height(Fill)
.style(container::bordered_box),
column![
control(
"Set Current",
Action::SetCurrent,
state.selected != state.current
),
control("Delete", Action::Delete, state.edited.states.len() > 1),
control("Move Up", Action::Move(true), state.selected > 0),
control(
"Move Down",
Action::Move(false),
state.selected + 1 < state.edited.states.len()
),
]
.spacing(8)
.width(130),
]
.spacing(12)
.height(Fill),
text(crate::t!("State name").into_owned()).size(13),
row![
text_input(crate::t!("State name").as_ref(), &state.name)
.on_input(move |name| send(Action::Name(name)))
.style(crate::ui::style::form::field_style),
button(text(crate::t!("Rename").into_owned())).on_press_maybe(
state
.name_error(true)
.is_none()
.then_some(send(Action::Rename))
),
]
.spacing(8),
row![
pick_list(
Some(state.new_visibility),
[
NewVisibility::Keep,
NewVisibility::Show,
NewVisibility::Hide
],
|mode: &NewVisibility| mode.to_string()
)
.on_select(move |mode| send(Action::NewVisibility(mode)))
.width(Fill),
button(text(crate::t!("New").into_owned())).on_press_maybe(
state
.name_error(false)
.is_none()
.then_some(send(Action::New))
),
]
.spacing(8),
text(state.error.clone().unwrap_or_default())
.size(12)
.style(text::danger),
row![
Space::new().width(Fill),
button(text(crate::t!("OK").into_owned()))
.on_press(send(Action::Apply))
.style(button::primary),
button(text(crate::t!("Cancel").into_owned())).on_press(Message::CloseModal),
]
.spacing(8),
]
.spacing(10),
)
.padding(16)
.width(sizing.width)
.height(sizing.height)
.into()
}
+1
View File
@@ -10,6 +10,7 @@ pub mod block_definition;
pub mod block_edit_picker;
pub mod block_palette;
pub mod block_save_as;
pub mod block_visibility;
pub mod browser;
pub mod count_palette;
pub mod drafting_settings;