forked from 1b-agent/opencadstudio-upstream
Sheet sets: fly-out submenus, block previews, window-aspect named views
- Row menus open Set category / Place Callout Block as fly-out submenus (hover or right press; leftwards when the palette is docked right). - List of Blocks and Select Block show the Blocks palette's preview. - VIEW SAVE keeps the window's width/height ratio. - Placing view content in model space is refused with the reference message. - Drawings read for views, placement and blocks are cached by modification time. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
0d0fbafaa8
commit
a3c79be278
27 files changed
+296
-62
No files matched your search
@@ -378,6 +378,11 @@ impl<'a> Program<Message> for BlockPreviewCanvas<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A block preview (the palette's canvas) for other dialogs.
|
||||
pub(crate) fn preview_canvas<'a>(wires: &'a [WireModel], height: f32) -> Element<'a, Message> {
|
||||
canvas(BlockPreviewCanvas { wires }).width(Fill).height(Length::Fixed(height)).into()
|
||||
}
|
||||
|
||||
fn pm(m: BlockPaletteMsg) -> Message {
|
||||
Message::BlockPalette(m)
|
||||
}
|
||||
|
||||
+150
-51
@@ -82,6 +82,47 @@ pub struct SheetSetManager {
|
||||
pub placing: Option<Placing>,
|
||||
/// A named view to show once the sheet drawing being opened is up.
|
||||
pub pending_view: Option<String>,
|
||||
/// The fly-out submenu open in a row menu (its key).
|
||||
pub submenu: Option<String>,
|
||||
/// Block previews (`file|name`; no name: the whole drawing).
|
||||
pub previews: std::collections::HashMap<String, Vec<crate::scene::model::wire_model::WireModel>>,
|
||||
/// Drawings read for Model Views, placement and blocks, by path key.
|
||||
pub drawings: DrawingCache,
|
||||
}
|
||||
|
||||
/// Drawings read from disk with their modification time; a changed file is
|
||||
/// read again.
|
||||
#[derive(Default)]
|
||||
pub struct DrawingCache(pub std::collections::HashMap<String, (std::time::SystemTime, std::sync::Arc<codec::CadDocument>)>);
|
||||
|
||||
impl std::fmt::Debug for DrawingCache {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "DrawingCache({} drawings)", self.0.len())
|
||||
}
|
||||
}
|
||||
|
||||
impl DrawingCache {
|
||||
/// The drawing at `path`, read again when its modification time changed.
|
||||
pub fn get(&mut self, path: &str) -> Option<std::sync::Arc<codec::CadDocument>> {
|
||||
let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?;
|
||||
let key = ss::path_key(path);
|
||||
if let Some((t, doc)) = self.0.get(&key) {
|
||||
if *t == modified {
|
||||
return Some(doc.clone());
|
||||
}
|
||||
}
|
||||
let doc = std::sync::Arc::new(crate::io::load_file(std::path::Path::new(path)).ok()?);
|
||||
self.0.insert(key, (modified, doc.clone()));
|
||||
Some(doc)
|
||||
}
|
||||
}
|
||||
|
||||
/// A row menu item: an entry, a separator, or a fly-out submenu (label, key, items).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Mi {
|
||||
Entry(String, Option<Message>),
|
||||
Sep,
|
||||
Sub(String, String, Vec<(String, Option<Message>)>),
|
||||
}
|
||||
|
||||
/// What a sheet set point pick places on the current sheet.
|
||||
@@ -176,6 +217,8 @@ pub enum SheetSetMsg {
|
||||
ViewMenu(String, ViewAction),
|
||||
/// List of Blocks / Select Block.
|
||||
BlockListSelect(usize),
|
||||
/// A row menu's fly-out submenu opened (hovered) or closed.
|
||||
Submenu(Option<String>),
|
||||
BlockListAdd,
|
||||
BlockListDelete,
|
||||
SelectBlockBrowse,
|
||||
@@ -407,6 +450,8 @@ pub struct BlockList {
|
||||
pub category: Box<Category>,
|
||||
pub blocks: Vec<(String, String)>,
|
||||
pub selected: Option<usize>,
|
||||
/// Preview of the selected block (`file|name`).
|
||||
pub preview: Option<String>,
|
||||
}
|
||||
|
||||
/// Select Block: a drawing, taken whole or by its named blocks.
|
||||
@@ -417,6 +462,8 @@ pub struct SelectBlock {
|
||||
pub whole: bool,
|
||||
/// Block name, handle, checked.
|
||||
pub names: Vec<(String, String, bool)>,
|
||||
/// Preview shown (`file|name`).
|
||||
pub preview: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
@@ -564,7 +611,8 @@ fn plain_row<'a>(
|
||||
label: String,
|
||||
fold: Option<(bool, Message)>,
|
||||
double: Option<Message>,
|
||||
menu: Vec<(String, Option<Message>)>,
|
||||
menu: Vec<Mi>,
|
||||
open_sub: Option<(String, bool)>,
|
||||
) -> Element<'a, Message> {
|
||||
let arrow: Element<'a, Message> = match fold {
|
||||
Some((open, m)) => button(if open { crate::ui::icons::themed_arrow_down(10.0) } else { crate::ui::icons::themed_arrow_right(10.0) })
|
||||
@@ -589,27 +637,72 @@ fn plain_row<'a>(
|
||||
if menu.is_empty() {
|
||||
return area.into();
|
||||
}
|
||||
iced_aw::ContextMenu::new(area, move || {
|
||||
let items: Vec<Element<'static, Message>> = menu
|
||||
.iter()
|
||||
.map(|(label, m)| if label.is_empty() { menu_separator() } else { menu_entry(label.clone(), m.clone()) })
|
||||
.collect();
|
||||
container(iced::widget::Column::with_children(items).spacing(1))
|
||||
.padding(4)
|
||||
.width(Length::Fixed(240.0))
|
||||
.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: 4.0.into() },
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
})
|
||||
.into()
|
||||
iced_aw::ContextMenu::new(area, move || menu_panel(&menu, open_sub.as_ref())).into()
|
||||
}
|
||||
|
||||
/// Height of a menu entry and of a separator (with the column spacing).
|
||||
const MENU_ENTRY_H: f32 = 27.0;
|
||||
const MENU_SEP_H: f32 = 2.0;
|
||||
|
||||
fn menu_box<'b>(items: Vec<Element<'b, Message>>) -> Element<'b, Message> {
|
||||
container(iced::widget::Column::with_children(items).spacing(1))
|
||||
.padding(4)
|
||||
.width(Length::Fixed(240.0))
|
||||
.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: 4.0.into() },
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A row menu; hovering a submenu entry opens its items beside it (`open`:
|
||||
/// key and whether the fly-out goes on the left).
|
||||
fn menu_panel(menu: &[Mi], open: Option<&(String, bool)>) -> Element<'static, Message> {
|
||||
let mut items: Vec<Element<'static, Message>> = Vec::new();
|
||||
let mut flyout: Option<(f32, Element<'static, Message>)> = None;
|
||||
let mut y = 0.0;
|
||||
for item in menu {
|
||||
match item {
|
||||
Mi::Sep => {
|
||||
items.push(menu_separator());
|
||||
y += MENU_SEP_H;
|
||||
}
|
||||
Mi::Entry(label, m) => {
|
||||
items.push(mouse_area(menu_entry(label.clone(), m.clone())).on_enter(msg(SheetSetMsg::Submenu(None))).into());
|
||||
y += MENU_ENTRY_H;
|
||||
}
|
||||
Mi::Sub(label, key, sub) => {
|
||||
let open_here = open.is_some_and(|(k, _)| k == key);
|
||||
// Hover (or a right press) opens the fly-out; a click closes the menu.
|
||||
let entry = menu_entry(format!("{label} ▸"), Some(msg(SheetSetMsg::Submenu(None))));
|
||||
let open_msg = msg(SheetSetMsg::Submenu(Some(key.clone())));
|
||||
items.push(mouse_area(entry).on_enter(open_msg.clone()).on_right_press(open_msg).into());
|
||||
if open_here {
|
||||
let subs = sub.iter().map(|(l, m)| menu_entry(l.clone(), m.clone())).collect();
|
||||
flyout = Some((y, menu_box(subs)));
|
||||
}
|
||||
y += MENU_ENTRY_H;
|
||||
}
|
||||
}
|
||||
}
|
||||
let main = menu_box(items);
|
||||
match flyout {
|
||||
Some((offset, sub)) => {
|
||||
let sub = column![Space::new().height(Length::Fixed(offset)), sub];
|
||||
// A menu opened from a right-docked palette sits left of the cursor
|
||||
// (the overlay flips it at the window edge); its fly-out opens to
|
||||
// the left so the menu itself does not move.
|
||||
// ponytail: side from the dock, not the menu's actual position.
|
||||
if open.is_some_and(|(_, left)| *left) { row![sub, main] } else { row![main, sub] }.spacing(2).into()
|
||||
}
|
||||
None => main,
|
||||
}
|
||||
}
|
||||
|
||||
/// A menu item: translated label and message (an empty label is a separator).
|
||||
fn mi(label: &str, m: Option<Message>) -> (String, Option<Message>) {
|
||||
(if label.is_empty() { String::new() } else { t!(label).into_owned() }, m)
|
||||
fn mi(label: &str, m: Option<Message>) -> Mi {
|
||||
if label.is_empty() { Mi::Sep } else { Mi::Entry(t!(label).into_owned(), m) }
|
||||
}
|
||||
|
||||
fn tree_row<'a>(
|
||||
@@ -689,32 +782,32 @@ fn push_tree<'a>(
|
||||
/// A sheet view row and its menu: Display, Rename & Renumber, Set category
|
||||
/// (the categories as sub-items), Place Callout Block (the set's callout
|
||||
/// blocks as sub-items) and Place View Label Block.
|
||||
fn view_row<'a>(db: &SheetSetDatabase, view: &SsElement, depth: u16) -> Element<'a, Message> {
|
||||
fn view_row<'a>(state: &SheetSetManager, db: &SheetSetDatabase, view: &SsElement, depth: u16, left: bool) -> Element<'a, Message> {
|
||||
let id = view.id().to_string();
|
||||
let act = |a: ViewAction| Some(msg(SheetSetMsg::ViewMenu(id.clone(), a)));
|
||||
let mut menu = vec![mi("Display", act(ViewAction::Display)), mi("Rename & Renumber...", act(ViewAction::Rename)), mi("", None)];
|
||||
menu.push((format!("{} ▸", t!("Set category")), None));
|
||||
for c in db.view_categories() {
|
||||
menu.push((format!(" {}", c.prop("Name").unwrap_or("")), act(ViewAction::Category(c.id().to_string()))));
|
||||
}
|
||||
menu.push((format!("{} ▸", t!("Place Callout Block")), None));
|
||||
for b in db.callout_blocks() {
|
||||
menu.push((format!(" {}", b.prop("Name").unwrap_or("")), act(ViewAction::Callout(b.id().to_string()))));
|
||||
}
|
||||
menu.push(mi("Place View Label Block", act(ViewAction::Label)));
|
||||
plain_row(depth, crate::ui::icons::DOC, ss::number_and_title(view), None, act(ViewAction::Display), menu)
|
||||
let categories = db.view_categories().iter().map(|c| (c.prop("Name").unwrap_or("").to_string(), act(ViewAction::Category(c.id().to_string())))).collect();
|
||||
let callouts = db.callout_blocks().iter().map(|b| (b.prop("Name").unwrap_or("").to_string(), act(ViewAction::Callout(b.id().to_string())))).collect();
|
||||
let menu = vec![
|
||||
mi("Display", act(ViewAction::Display)),
|
||||
mi("Rename & Renumber...", act(ViewAction::Rename)),
|
||||
Mi::Sep,
|
||||
Mi::Sub(t!("Set category").into_owned(), format!("{id}|category"), categories),
|
||||
Mi::Sub(t!("Place Callout Block").into_owned(), format!("{id}|callout"), callouts),
|
||||
mi("Place View Label Block", act(ViewAction::Label)),
|
||||
];
|
||||
plain_row(depth, crate::ui::icons::DOC, ss::number_and_title(view), None, act(ViewAction::Display), menu, state.submenu.clone().map(|k| (k, left)))
|
||||
}
|
||||
|
||||
/// The Sheet Views page: the set, then its view categories (or sheets) and their views.
|
||||
fn sheet_views_rows<'a>(state: &SheetSetManager, db: &SheetSetDatabase) -> Vec<Element<'a, Message>> {
|
||||
fn sheet_views_rows<'a>(state: &SheetSetManager, db: &SheetSetDatabase, left: bool) -> Vec<Element<'a, Message>> {
|
||||
let new_category = || mi("New View Category...", Some(msg(SheetSetMsg::NewCategory)));
|
||||
let mut rows = vec![plain_row(0, SET_ICON, db.name().to_string(), None, None, vec![new_category()])];
|
||||
let mut rows = vec![plain_row(0, SET_ICON, db.name().to_string(), None, None, vec![new_category()], None)];
|
||||
let views: Vec<&SsElement> = db.sheets().into_iter().flat_map(|s| db.sheet_views(s)).collect();
|
||||
if state.by_category {
|
||||
let named = db.view_categories();
|
||||
// Views of the unnamed (default) category sit directly under the set.
|
||||
for v in views.iter().filter(|v| !SheetSetDatabase::view_category_of(v).is_some_and(|c| named.iter().any(|n| n.id() == c))) {
|
||||
rows.push(view_row(db, v, 1));
|
||||
rows.push(view_row(state, db, v, 1, left));
|
||||
}
|
||||
for c in named {
|
||||
let id = c.id().to_string();
|
||||
@@ -729,9 +822,10 @@ fn sheet_views_rows<'a>(state: &SheetSetManager, db: &SheetSetDatabase) -> Vec<E
|
||||
mi("Properties...", Some(msg(SheetSetMsg::CategoryProperties(id.clone())))),
|
||||
mi("Remove", Some(msg(SheetSetMsg::CategoryRemove(id.clone())))),
|
||||
],
|
||||
None,
|
||||
));
|
||||
for v in views.iter().filter(|v| SheetSetDatabase::view_category_of(v) == Some(id.as_str())) {
|
||||
rows.push(view_row(db, v, 2));
|
||||
rows.push(view_row(state, db, v, 2, left));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -740,9 +834,9 @@ fn sheet_views_rows<'a>(state: &SheetSetManager, db: &SheetSetDatabase) -> Vec<E
|
||||
if views.is_empty() {
|
||||
continue;
|
||||
}
|
||||
rows.push(plain_row(1, crate::ui::icons::DOC, ss::number_and_title(sheet), None, None, vec![]));
|
||||
rows.push(plain_row(1, crate::ui::icons::DOC, ss::number_and_title(sheet), None, None, vec![], None));
|
||||
for v in views {
|
||||
rows.push(view_row(db, v, 2));
|
||||
rows.push(view_row(state, db, v, 2, left));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -767,6 +861,7 @@ fn model_views_rows<'a>(state: &SheetSetManager) -> Vec<Element<'a, Message>> {
|
||||
mi("Add New Location...", Some(msg(SheetSetMsg::AddLocation))),
|
||||
mi("Remove Location", Some(msg(SheetSetMsg::RemoveLocation(id.clone())))),
|
||||
],
|
||||
None,
|
||||
));
|
||||
for file in files {
|
||||
let path = std::path::Path::new(folder).join(file).to_string_lossy().to_string();
|
||||
@@ -784,6 +879,7 @@ fn model_views_rows<'a>(state: &SheetSetManager) -> Vec<Element<'a, Message>> {
|
||||
mi("Place on Sheet", Some(msg(SheetSetMsg::PlaceOnSheet(path.clone(), None)))),
|
||||
mi("See Model Space Views", Some(msg(SheetSetMsg::SeeViews(path.clone())))),
|
||||
],
|
||||
None,
|
||||
));
|
||||
if open {
|
||||
for v in state.model_views.get(&key).cloned().unwrap_or_default() {
|
||||
@@ -798,6 +894,7 @@ fn model_views_rows<'a>(state: &SheetSetManager) -> Vec<Element<'a, Message>> {
|
||||
mi("", None),
|
||||
mi("Place on Sheet", Some(msg(SheetSetMsg::PlaceOnSheet(path.clone(), Some(v))))),
|
||||
],
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -822,7 +919,8 @@ fn heading(label: &str) -> Element<'static, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn view<'a>(state: &'a SheetSetManager, width: f32, auto_collapse: bool) -> Element<'a, Message> {
|
||||
/// `dock_right`: the palette is docked on the right (fly-outs open leftwards).
|
||||
pub fn view<'a>(state: &'a SheetSetManager, width: f32, auto_collapse: bool, dock_right: bool) -> Element<'a, Message> {
|
||||
let title_bar =
|
||||
crate::ui::dock::title_bar(PanelId::SheetSetManager, t!("Sheet Set Manager").into_owned(), auto_collapse);
|
||||
let mut picks: Vec<Pick> =
|
||||
@@ -888,7 +986,7 @@ pub fn view<'a>(state: &'a SheetSetManager, width: f32, auto_collapse: bool) ->
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Center);
|
||||
column![header, scrollable(iced::widget::Column::with_children(sheet_views_rows(state, db)).spacing(1)).height(Fill)]
|
||||
column![header, scrollable(iced::widget::Column::with_children(sheet_views_rows(state, db, dock_right)).spacing(1)).height(Fill)]
|
||||
.spacing(6)
|
||||
.into()
|
||||
}
|
||||
@@ -1537,15 +1635,16 @@ fn category_view<'a>(c: &'a Category) -> Element<'a, Message> {
|
||||
col.push(ok_cancel("OK")).into()
|
||||
}
|
||||
|
||||
fn preview_box<'a>() -> Element<'a, Message> {
|
||||
container(text(t!("preview").into_owned()).size(11).style(muted_style))
|
||||
.center_x(Fill)
|
||||
.center_y(Fill)
|
||||
.style(card_style)
|
||||
.into()
|
||||
/// A block preview (the Blocks palette's), or the empty preview box.
|
||||
fn preview_box<'a>(previews: &'a std::collections::HashMap<String, Vec<crate::scene::model::wire_model::WireModel>>, key: Option<&String>, height: f32) -> Element<'a, Message> {
|
||||
let inner: Element<'a, Message> = match key.and_then(|k| previews.get(k)) {
|
||||
Some(w) if !w.is_empty() => crate::ui::window::block_palette::preview_canvas(w, height - 8.0),
|
||||
_ => text(t!("preview").into_owned()).size(11).style(muted_style).into(),
|
||||
};
|
||||
container(inner).center_x(Fill).center_y(Length::Fixed(height)).padding(4).style(card_style).into()
|
||||
}
|
||||
|
||||
fn block_list_view<'a>(b: &'a BlockList) -> Element<'a, Message> {
|
||||
fn block_list_view<'a>(b: &'a BlockList, state: &'a SheetSetManager) -> Element<'a, Message> {
|
||||
let rows = b.blocks.iter().enumerate().map(|(i, (_, label))| {
|
||||
button(text(label.clone()).size(12))
|
||||
.on_press(msg(SheetSetMsg::BlockListSelect(i)))
|
||||
@@ -1565,7 +1664,7 @@ fn block_list_view<'a>(b: &'a BlockList) -> Element<'a, Message> {
|
||||
.style(button_style(false))
|
||||
.padding([5, 12])
|
||||
.width(Fill),
|
||||
preview_box(),
|
||||
preview_box(&state.previews, b.preview.as_ref(), 84.0),
|
||||
]
|
||||
.spacing(6)
|
||||
.width(Length::Fixed(130.0));
|
||||
@@ -1593,7 +1692,7 @@ fn block_list_view<'a>(b: &'a BlockList) -> Element<'a, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn select_block_view<'a>(b: &'a SelectBlock) -> Element<'a, Message> {
|
||||
fn select_block_view<'a>(b: &'a SelectBlock, state: &'a SheetSetManager) -> Element<'a, Message> {
|
||||
let names = b.names.iter().enumerate().map(|(i, (name, _, on))| {
|
||||
let c = checkbox(*on).label(name.clone()).text_size(12).size(14);
|
||||
if b.whole { c.into() } else { c.on_toggle(move |v| msg(SheetSetMsg::SelectBlockCheck(i, v))).into() }
|
||||
@@ -1615,7 +1714,7 @@ fn select_block_view<'a>(b: &'a SelectBlock) -> Element<'a, Message> {
|
||||
.height(Length::Fixed(110.0))
|
||||
.width(Fill)
|
||||
.style(card_style),
|
||||
container(preview_box()).width(Length::Fixed(130.0)).height(Length::Fixed(110.0)),
|
||||
container(preview_box(&state.previews, b.preview.as_ref(), 110.0)).width(Length::Fixed(130.0)),
|
||||
]
|
||||
.spacing(10),
|
||||
]
|
||||
@@ -1658,8 +1757,8 @@ pub fn dialog_view<'a>(state: &'a SheetSetManager, sizing: crate::ui::modal::Mod
|
||||
Some(SsDialog::Confirm(_, _, q)) => confirm_view(q),
|
||||
Some(SsDialog::Template(t)) => template_view(t),
|
||||
Some(SsDialog::Category(c)) => category_view(c),
|
||||
Some(SsDialog::BlockList(b)) => block_list_view(b),
|
||||
Some(SsDialog::SelectBlock(b)) => select_block_view(b),
|
||||
Some(SsDialog::BlockList(b)) => block_list_view(b, state),
|
||||
Some(SsDialog::SelectBlock(b)) => select_block_view(b, state),
|
||||
None => Space::new().into(),
|
||||
};
|
||||
container(body).padding([10, 12]).width(sizing.width).into()
|
||||
|
||||
Reference in new issue
Block a user