refactor: group ui/ statusbar and window subdirs

Continue tidying the flat ui/ root:

- ui/statusbar/: statusbar.rs becomes statusbar/mod.rs (its
  crate::ui::statusbar::* paths are preserved) and absorbs
  statusbar_config.rs + statusbar_menu.rs.
- ui/window/: the standalone manager/dialog windows move here —
  about, layout_manager, page_setup, plugin_manager, shortcuts,
  layers, update_notice, open_progress. The crate::ui::LayerPanel
  re-export is repointed so callers are unchanged.

Call sites repointed to the new paths; no compat re-exports beyond the
existing LayerPanel one. ui/ root drops from 21 to 10 flat files with
five subdirs (ribbon, style, popup, statusbar, window).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-26 23:52:26 +03:00
commit a502379048
17 changed files with 29 additions and 27 deletions

140
src/ui/window/about.rs Normal file
View file

@ -0,0 +1,140 @@
use crate::app::Message;
use iced::widget::{button, column, container, row, text};
use iced::{Background, Border, Color, Element, Theme};
const BG: Color = Color {
r: 0.15,
g: 0.15,
b: 0.15,
a: 1.0,
};
const PANEL: Color = Color {
r: 0.12,
g: 0.12,
b: 0.12,
a: 1.0,
};
const BORDER: Color = Color {
r: 0.30,
g: 0.30,
b: 0.30,
a: 1.0,
};
const DIM: 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 WHITE: Color = Color {
r: 0.92,
g: 0.92,
b: 0.92,
a: 1.0,
};
fn info_row<'a>(label: &'static str, value: String) -> Element<'a, Message> {
row![
text(label).size(11).color(DIM).width(100),
text(value).size(11).color(WHITE),
]
.spacing(8)
.align_y(iced::Center)
.padding([3, 0])
.into()
}
pub fn view_window<'a>() -> Element<'a, Message> {
let version = env!("CARGO_PKG_VERSION");
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let logo = container(
column![
text("Open CAD Studio").size(32).color(ACCENT),
text("CAD application for Architecture & Engineering")
.size(11)
.color(DIM),
]
.spacing(4)
.align_x(iced::Center),
)
.padding(iced::Padding {
top: 20.0,
right: 0.0,
bottom: 16.0,
left: 0.0,
})
.align_x(iced::Center);
let info_block = container(
column![
info_row("Version", format!("v{}", version)),
info_row("Platform", os.to_string()),
info_row("Arch", arch.to_string()),
]
.spacing(2)
.padding([12, 16]),
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(PANEL)),
border: Border {
color: BORDER,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
});
let copy_btn = button(text("Copy Info").size(11))
.on_press(Message::AboutCopyInfo)
.style(|_: &Theme, st| button::Style {
background: Some(Background::Color(match st {
button::Status::Hovered | button::Status::Pressed => Color {
r: 0.20,
g: 0.42,
b: 0.72,
a: 1.0,
},
_ => ACCENT,
})),
text_color: WHITE,
border: Border {
radius: 4.0.into(),
..Default::default()
},
..Default::default()
})
.padding([6, 16]);
let footer = row![copy_btn]
.align_y(iced::Center)
.padding(iced::Padding {
top: 12.0,
right: 0.0,
bottom: 0.0,
left: 0.0,
});
container(
column![logo, info_block, footer]
.spacing(0)
.padding(iced::Padding {
top: 0.0,
right: 20.0,
bottom: 20.0,
left: 20.0,
}),
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)),
..Default::default()
})
.into()
}

1012
src/ui/window/layers.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,353 @@
//! Layout Manager window — fills the entire OS window.
use crate::app::Message;
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
use iced::{Background, Border, Color, Element, Fill, Theme};
const TB: Color = Color {
r: 0.13,
g: 0.13,
b: 0.13,
a: 1.0,
};
const 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: Color = Color {
r: 0.88,
g: 0.88,
b: 0.88,
a: 1.0,
};
const DIM: 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: Color = Color {
r: 0.20,
g: 0.40,
b: 0.70,
a: 1.0,
};
const FIELD: Color = Color {
r: 0.10,
g: 0.10,
b: 0.10,
a: 1.0,
};
const LIST: Color = Color {
r: 0.12,
g: 0.12,
b: 0.12,
a: 1.0,
};
const WARN: Color = Color {
r: 0.80,
g: 0.35,
b: 0.25,
a: 1.0,
};
fn btn_s(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
move |_: &Theme, st| button::Style {
background: Some(Background::Color(match (accent, st) {
(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,
border: Border {
color: BORDER,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
}
}
fn list_item(active: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
move |_: &Theme, st| button::Style {
background: Some(Background::Color(match (active, st) {
(true, _) => ACTIVE,
(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,
..Default::default()
}
}
fn field_style(_: &Theme, _: text_input::Status) -> text_input::Style {
text_input::Style {
background: Background::Color(FIELD),
border: Border {
color: BORDER,
width: 1.0,
radius: 3.0.into(),
},
icon: TEXT,
placeholder: DIM,
value: TEXT,
selection: ACCENT,
}
}
fn hdivider<'a>() -> Element<'a, Message> {
container(Space::new().width(Fill).height(1))
.width(Fill)
.height(1)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BORDER)),
..Default::default()
})
.into()
}
fn vsep<'a>() -> Element<'a, Message> {
container(Space::new().width(1).height(Fill))
.width(1)
.height(Fill)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BORDER)),
..Default::default()
})
.into()
}
pub fn view_window<'a>(
layouts: Vec<String>,
selected: &'a str,
rename_buf: &'a str,
current: String,
) -> Element<'a, Message> {
let is_model = selected == "Model";
// ── Toolbar ───────────────────────────────────────────────────────────
let toolbar = container(
row![
button(text("New Layout").size(11))
.on_press(Message::LayoutManagerNew)
.style(btn_s(false))
.padding([4, 10]),
button(text("Delete").size(11))
.on_press(Message::LayoutManagerDelete)
.style(move |_: &Theme, st| button::Style {
background: Some(Background::Color(match st {
button::Status::Hovered | button::Status::Pressed => Color {
r: 0.60,
g: 0.20,
b: 0.18,
a: 1.0
},
_ => Color {
r: 0.22,
g: 0.22,
b: 0.22,
a: 1.0
},
})),
text_color: if is_model { DIM } else { WARN },
border: Border {
color: BORDER,
width: 1.0,
radius: 4.0.into()
},
..Default::default()
})
.padding([4, 10]),
Space::new().width(Fill),
button(
row![
crate::ui::icons::tinted(crate::ui::icons::TRI_LEFT_B, 9.0, Color::WHITE),
text("Move Left").size(11),
]
.spacing(4)
.align_y(iced::Center),
)
.on_press(Message::LayoutManagerMoveLeft)
.style(btn_s(false))
.padding([4, 8]),
button(
row![
text("Move Right").size(11),
crate::ui::icons::arrow_right(9.0, Color::WHITE),
]
.spacing(4)
.align_y(iced::Center),
)
.on_press(Message::LayoutManagerMoveRight)
.style(btn_s(false))
.padding([4, 8]),
button(text("Set Current").size(11))
.on_press(Message::LayoutManagerSetCurrent)
.style(btn_s(true))
.padding([4, 10]),
]
.spacing(4)
.align_y(iced::Center),
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(TB)),
..Default::default()
})
.width(Fill)
.padding([5, 8]);
// ── Left: Layout list ─────────────────────────────────────────────────
let list_items: Vec<Element<'_, Message>> = layouts
.iter()
.map(|name| {
let is_sel = name.as_str() == selected;
let is_cur = name.as_str() == current.as_str();
let mut item_row = row![text(name.clone()).size(12)]
.spacing(5)
.align_y(iced::Center);
if is_cur {
item_row = item_row.push(crate::ui::icons::tinted(
crate::ui::icons::TRI_LEFT_B,
8.0,
Color::WHITE,
));
}
button(item_row)
.on_press(Message::LayoutManagerSelect(name.clone()))
.style(list_item(is_sel))
.padding([5, 10])
.width(Fill)
.into()
})
.collect();
let layout_list = container(
column![
text("Layouts").size(10).color(DIM),
container(scrollable(column(list_items).spacing(2)).height(Fill))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(LIST)),
border: Border {
color: BORDER,
width: 1.0,
radius: 3.0.into()
},
..Default::default()
})
.width(Fill)
.height(Fill)
.padding(2),
]
.spacing(4)
.height(Fill),
)
.width(220)
.height(Fill)
.padding(iced::Padding {
top: 12.0,
right: 8.0,
bottom: 12.0,
left: 12.0,
});
// ── Right: Details + rename ───────────────────────────────────────────
let details = container(
column![
text(if is_model {
"Model Space"
} else {
"Paper Space Layout"
})
.size(13)
.color(TEXT),
Space::new().height(8),
row![
text("Name:").size(11).color(DIM).width(80),
text(selected).size(11),
]
.spacing(8)
.align_y(iced::Center),
row![
text("Status:").size(11).color(DIM).width(80),
text(if selected == current.as_str() {
"Active"
} else {
"Inactive"
})
.size(11)
.color(if selected == current.as_str() {
ACCENT
} else {
DIM
}),
]
.spacing(8)
.align_y(iced::Center),
Space::new().height(16),
text("Rename").size(10).color(DIM),
row![
text_input("New name…", rename_buf)
.on_input(Message::LayoutManagerRenameBuf)
.on_submit(Message::LayoutManagerRenameCommit)
.style(field_style)
.size(11)
.padding([4, 8]),
button(text("OK").size(11))
.on_press(Message::LayoutManagerRenameCommit)
.style(btn_s(true))
.padding([4, 10]),
]
.spacing(6)
.align_y(iced::Center),
]
.spacing(8),
)
.width(Fill)
.padding([12, 12]);
let body = row![layout_list, vsep(), details].height(Fill);
container(column![toolbar, hdivider(), body].spacing(0))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)),
..Default::default()
})
.width(Fill)
.height(Fill)
.into()
}

8
src/ui/window/mod.rs Normal file
View file

@ -0,0 +1,8 @@
pub mod about;
pub mod layout_manager;
pub mod page_setup;
pub mod plugin_manager;
pub mod shortcuts;
pub mod layers;
pub mod update_notice;
pub mod open_progress;

View file

@ -0,0 +1,241 @@
//! Modal overlay shown while a CAD file is being loaded.
//!
//! Displays the file name, size, current phase, an indeterminate animated
//! progress bar, and a Cancel button.
use iced::time::Instant;
use iced::widget::{button, column, container, row, stack, text, Space};
use iced::{Background, Border, Color, Element, Fill, Length, Theme};
use std::sync::atomic::Ordering;
use crate::app::{
Message, OpenProgress, OPEN_PHASE_CACHING, OPEN_PHASE_FINALIZING, OPEN_PHASE_PARSING,
OPEN_PHASE_READING,
};
const CARD_WIDTH: f32 = 420.0;
const BAR_TRACK_WIDTH: f32 = 380.0;
const BAR_TRACK_HEIGHT: f32 = 6.0;
const BAR_WINDOW_WIDTH: f32 = 100.0;
/// Period of one back-and-forth bounce, in milliseconds.
const BAR_PERIOD_MS: f32 = 1800.0;
fn phase_label(phase: u8) -> &'static str {
match phase {
OPEN_PHASE_READING => "Reading file…",
OPEN_PHASE_PARSING => "Parsing entities…",
OPEN_PHASE_CACHING => "Building scene caches…",
OPEN_PHASE_FINALIZING => "Finalizing…",
_ => "Working…",
}
}
fn format_size(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
let b = bytes as f64;
if b >= GB {
format!("{:.2} GB", b / GB)
} else if b >= MB {
format!("{:.1} MB", b / MB)
} else if b >= KB {
format!("{:.1} KB", b / KB)
} else {
format!("{bytes} B")
}
}
/// Compute the left-offset of the moving highlight inside the track.
/// Bounces left↔right using a triangle wave so the user sees motion even when
/// the actual phase atomic stays put for a while.
fn bar_offset(elapsed_ms: f32) -> f32 {
let travel = (BAR_TRACK_WIDTH - BAR_WINDOW_WIDTH).max(0.0);
let cycle = (elapsed_ms / BAR_PERIOD_MS).fract();
let tri = if cycle < 0.5 {
cycle * 2.0
} else {
(1.0 - cycle) * 2.0
};
tri * travel
}
pub fn view<'a>(progress: &'a OpenProgress, now: Instant) -> Element<'a, Message> {
let phase = progress.phase.load(Ordering::Relaxed);
let elapsed_ms = now.saturating_duration_since(progress.started).as_millis() as f32;
// ── Animated indeterminate bar ────────────────────────────────────────
let offset = bar_offset(elapsed_ms);
let trailing = (BAR_TRACK_WIDTH - BAR_WINDOW_WIDTH - offset).max(0.0);
let bar_window: Element<'_, Message> = container(Space::new().width(Length::Fixed(BAR_WINDOW_WIDTH)).height(Length::Fixed(BAR_TRACK_HEIGHT)))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color {
r: 0.30,
g: 0.62,
b: 0.95,
a: 1.0,
})),
border: Border {
radius: 3.0.into(),
..Default::default()
},
..Default::default()
})
.into();
let bar_moving: Element<'_, Message> = row![
Space::new()
.width(Length::Fixed(offset))
.height(Length::Fixed(BAR_TRACK_HEIGHT)),
bar_window,
Space::new()
.width(Length::Fixed(trailing))
.height(Length::Fixed(BAR_TRACK_HEIGHT)),
]
.into();
let bar_track: Element<'_, Message> = container(
stack![
container(Space::new().width(Length::Fixed(BAR_TRACK_WIDTH)).height(Length::Fixed(BAR_TRACK_HEIGHT)))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color {
r: 0.18,
g: 0.18,
b: 0.18,
a: 1.0,
})),
border: Border {
radius: 3.0.into(),
..Default::default()
},
..Default::default()
}),
bar_moving,
]
.width(Length::Fixed(BAR_TRACK_WIDTH))
.height(Length::Fixed(BAR_TRACK_HEIGHT)),
)
.into();
// ── Card body ────────────────────────────────────────────────────────
let title = text("Opening file")
.size(15)
.color(Color::WHITE);
let name_line = text(format!(
"{} ({})",
progress.name,
format_size(progress.size_bytes)
))
.size(13)
.color(Color {
r: 0.82,
g: 0.82,
b: 0.82,
a: 1.0,
});
let phase_line = text(phase_label(phase))
.size(12)
.color(Color {
r: 0.70,
g: 0.80,
b: 0.95,
a: 1.0,
});
let cancel_btn: Element<'_, Message> = button(text("Cancel").size(12).color(Color::WHITE))
.on_press(Message::OpenCancel)
.style(|_: &Theme, status| {
let bg = match status {
button::Status::Hovered => Color {
r: 0.32,
g: 0.32,
b: 0.32,
a: 1.0,
},
button::Status::Pressed => Color {
r: 0.42,
g: 0.18,
b: 0.18,
a: 1.0,
},
_ => Color {
r: 0.22,
g: 0.22,
b: 0.22,
a: 1.0,
},
};
button::Style {
background: Some(Background::Color(bg)),
border: Border {
color: Color {
r: 0.40,
g: 0.40,
b: 0.40,
a: 1.0,
},
width: 1.0,
radius: 3.0.into(),
},
text_color: Color::WHITE,
..Default::default()
}
})
.padding([4, 14])
.into();
let cancel_row: Element<'_, Message> = container(cancel_btn).align_right(Fill).into();
let card = container(
column![title, name_line, bar_track, phase_line, cancel_row]
.spacing(10)
.width(Length::Fixed(CARD_WIDTH)),
)
.padding([18, 22])
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color {
r: 0.13,
g: 0.13,
b: 0.13,
a: 0.98,
})),
border: Border {
color: Color {
r: 0.45,
g: 0.45,
b: 0.45,
a: 1.0,
},
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
});
// ── Backdrop (click-blocker + dim) ────────────────────────────────────
let backdrop: Element<'_, Message> = container(Space::new().width(Fill).height(Fill))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 0.55,
})),
..Default::default()
})
.width(Fill)
.height(Fill)
.into();
let centered: Element<'_, Message> = container(card)
.center_x(Fill)
.center_y(Fill)
.width(Fill)
.height(Fill)
.into();
stack![backdrop, centered].width(Fill).height(Fill).into()
}

384
src/ui/window/page_setup.rs Normal file
View file

@ -0,0 +1,384 @@
//! Page Setup window — fills the entire OS window.
use crate::app::Message;
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
use iced::{Background, Border, Color, Element, Fill, Theme};
const TB: Color = Color {
r: 0.13,
g: 0.13,
b: 0.13,
a: 1.0,
};
const 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: Color = Color {
r: 0.88,
g: 0.88,
b: 0.88,
a: 1.0,
};
const DIM: 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: Color = Color {
r: 0.20,
g: 0.40,
b: 0.70,
a: 1.0,
};
const FIELD: Color = Color {
r: 0.10,
g: 0.10,
b: 0.10,
a: 1.0,
};
fn btn(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
move |_: &Theme, st| button::Style {
background: Some(Background::Color(match (accent, st) {
(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,
border: Border {
color: BORDER,
width: 1.0,
radius: 4.0.into(),
},
shadow: iced::Shadow::default(),
snap: false,
}
}
fn pill(active: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
move |_: &Theme, st| button::Style {
background: Some(Background::Color(match (active, st) {
(true, _) => ACTIVE,
(false, button::Status::Hovered | button::Status::Pressed) => Color {
r: 0.28,
g: 0.28,
b: 0.28,
a: 1.0,
},
_ => Color {
r: 0.20,
g: 0.20,
b: 0.20,
a: 1.0,
},
})),
text_color: TEXT,
border: Border {
color: BORDER,
width: 1.0,
radius: 3.0.into(),
},
shadow: iced::Shadow::default(),
snap: false,
}
}
fn field_style(_: &Theme, _: text_input::Status) -> text_input::Style {
text_input::Style {
background: Background::Color(FIELD),
border: Border {
color: BORDER,
width: 1.0,
radius: 3.0.into(),
},
icon: TEXT,
placeholder: DIM,
value: TEXT,
selection: ACCENT,
}
}
fn hdivider<'a>() -> Element<'a, Message> {
container(Space::new().width(Fill).height(1))
.width(Fill)
.height(1)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BORDER)),
..Default::default()
})
.into()
}
fn section_label<'a>(s: &'static str) -> Element<'a, Message> {
text(s).size(11).color(DIM).into()
}
pub fn view_window<'a>(
w_buf: &'a str,
h_buf: &'a str,
plot_area: &'a str,
center: bool,
offset_x: &'a str,
offset_y: &'a str,
rotation: &'a str,
scale: &'a str,
) -> Element<'a, Message> {
// ── Toolbar ───────────────────────────────────────────────────────────
let toolbar = container(
row![
button(text("Cancel").size(12))
.on_press(Message::PageSetupClose)
.style(btn(false))
.padding([4, 14]),
Space::new().width(Fill),
button(text("OK").size(12))
.on_press(Message::PageSetupCommit)
.style(btn(true))
.padding([4, 20]),
]
.align_y(iced::Center),
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(TB)),
..Default::default()
})
.width(Fill)
.padding([5, 10]);
let lbl = |s: &'static str| text(s).size(11).color(DIM).width(130);
// ── Paper size presets ────────────────────────────────────────────────
let presets1 = row![
button(text("A4 P").size(10))
.on_press(Message::PageSetupPreset("A4 Portrait".into()))
.style(pill(false))
.padding([3, 6]),
button(text("A4 L").size(10))
.on_press(Message::PageSetupPreset("A4 Landscape".into()))
.style(pill(false))
.padding([3, 6]),
button(text("A3 P").size(10))
.on_press(Message::PageSetupPreset("A3 Portrait".into()))
.style(pill(false))
.padding([3, 6]),
button(text("A3 L").size(10))
.on_press(Message::PageSetupPreset("A3 Landscape".into()))
.style(pill(false))
.padding([3, 6]),
]
.spacing(4);
let presets2 = row![
button(text("A2 L").size(10))
.on_press(Message::PageSetupPreset("A2 Landscape".into()))
.style(pill(false))
.padding([3, 6]),
button(text("A1 L").size(10))
.on_press(Message::PageSetupPreset("A1 Landscape".into()))
.style(pill(false))
.padding([3, 6]),
button(text("A0 L").size(10))
.on_press(Message::PageSetupPreset("A0 Landscape".into()))
.style(pill(false))
.padding([3, 6]),
button(text("Letter").size(10))
.on_press(Message::PageSetupPreset("Letter Landscape".into()))
.style(pill(false))
.padding([3, 6]),
]
.spacing(4);
// ── Rotation buttons ──────────────────────────────────────────────────
let rot_row = row![
button(text("0°").size(10))
.on_press(Message::PageSetupRotation("0".into()))
.style(pill(rotation == "0"))
.padding([3, 8]),
button(text("90°").size(10))
.on_press(Message::PageSetupRotation("90".into()))
.style(pill(rotation == "90"))
.padding([3, 8]),
button(text("180°").size(10))
.on_press(Message::PageSetupRotation("180".into()))
.style(pill(rotation == "180"))
.padding([3, 8]),
button(text("270°").size(10))
.on_press(Message::PageSetupRotation("270".into()))
.style(pill(rotation == "270"))
.padding([3, 8]),
]
.spacing(4);
// ── Scale buttons ─────────────────────────────────────────────────────
let scale_row1 = row![
button(text("Fit").size(10))
.on_press(Message::PageSetupScale("Fit".into()))
.style(pill(scale == "Fit"))
.padding([3, 8]),
button(text("1:1").size(10))
.on_press(Message::PageSetupScale("1:1".into()))
.style(pill(scale == "1:1"))
.padding([3, 8]),
button(text("1:2").size(10))
.on_press(Message::PageSetupScale("1:2".into()))
.style(pill(scale == "1:2"))
.padding([3, 8]),
button(text("1:5").size(10))
.on_press(Message::PageSetupScale("1:5".into()))
.style(pill(scale == "1:5"))
.padding([3, 8]),
button(text("1:10").size(10))
.on_press(Message::PageSetupScale("1:10".into()))
.style(pill(scale == "1:10"))
.padding([3, 8]),
]
.spacing(4);
let scale_row2 = row![
button(text("1:20").size(10))
.on_press(Message::PageSetupScale("1:20".into()))
.style(pill(scale == "1:20"))
.padding([3, 8]),
button(text("1:50").size(10))
.on_press(Message::PageSetupScale("1:50".into()))
.style(pill(scale == "1:50"))
.padding([3, 8]),
button(text("1:100").size(10))
.on_press(Message::PageSetupScale("1:100".into()))
.style(pill(scale == "1:100"))
.padding([3, 8]),
button(text("2:1").size(10))
.on_press(Message::PageSetupScale("2:1".into()))
.style(pill(scale == "2:1"))
.padding([3, 8]),
]
.spacing(4);
// ── Main scrollable form ──────────────────────────────────────────────
let form = column![
section_label("Paper Size"),
presets1,
presets2,
row![
lbl("Width (mm)"),
text_input("297", w_buf)
.on_input(Message::PageSetupWidthEdit)
.on_submit(Message::PageSetupCommit)
.style(field_style)
.size(12)
.width(90),
]
.spacing(8)
.align_y(iced::Center),
row![
lbl("Height (mm)"),
text_input("210", h_buf)
.on_input(Message::PageSetupHeightEdit)
.on_submit(Message::PageSetupCommit)
.style(field_style)
.size(12)
.width(90),
]
.spacing(8)
.align_y(iced::Center),
hdivider(),
section_label("Plot Area"),
row![
button(text("Layout").size(10))
.on_press(Message::PageSetupPlotArea("Layout".into()))
.style(pill(plot_area == "Layout"))
.padding([3, 8]),
button(text("Extents").size(10))
.on_press(Message::PageSetupPlotArea("Extents".into()))
.style(pill(plot_area == "Extents"))
.padding([3, 8]),
]
.spacing(6),
hdivider(),
section_label("Position"),
button(
row![
crate::ui::icons::check_cell(center, Color::WHITE),
text("Center on page").size(11),
]
.spacing(2)
.align_y(iced::Center)
)
.on_press(Message::PageSetupCenterToggle)
.style(pill(center))
.padding([4, 10]),
row![
lbl("Offset X (mm)"),
text_input("0", offset_x)
.on_input(Message::PageSetupOffsetXEdit)
.style(field_style)
.size(12)
.width(90),
]
.spacing(8)
.align_y(iced::Center),
row![
lbl("Offset Y (mm)"),
text_input("0", offset_y)
.on_input(Message::PageSetupOffsetYEdit)
.style(field_style)
.size(12)
.width(90),
]
.spacing(8)
.align_y(iced::Center),
hdivider(),
section_label("Rotation"),
rot_row,
hdivider(),
section_label("Plot Scale"),
scale_row1,
scale_row2,
]
.spacing(10)
.padding(16)
.width(Fill);
let content = scrollable(form).width(Fill).height(Fill);
container(column![toolbar, hdivider(), content].spacing(0))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)),
..Default::default()
})
.width(Fill)
.height(Fill)
.into()
}

View file

@ -0,0 +1,370 @@
//! Plugin Manager window — lists the add-ons compiled into this build and lets
//! the user enable/disable each one. A disabled plugin keeps its manifest
//! listed but drops its ribbon tab and command dispatch (persisted across
//! launches). Dynamic loading still comes with the phase-2 loader; see
//! `docs/plugin-architecture.md`.
use crate::app::Message;
use crate::plugin::external::{ExternalPlugin, RegistryEntry};
use iced::widget::{button, column, container, pick_list, row, scrollable, text, text_input, Space};
use iced::{Background, Border, Color, Element, Fill, Theme};
use rustc_hash::{FxHashMap, FxHashSet};
/// Marketplace state passed to the Plugin Manager view.
pub struct MarketView<'a> {
pub registry: &'a [RegistryEntry],
pub input: &'a str,
pub repos: &'a [String],
pub release_tags: &'a FxHashMap<String, Vec<String>>,
pub selected_tag: &'a FxHashMap<String, String>,
pub status: &'a str,
}
// Register the command names for autocomplete.
inventory::submit!(crate::command::CommandRegistration {
names: &["PLUGINS", "PLUGINMANAGER"]
});
const BG: Color = Color {
r: 0.15,
g: 0.15,
b: 0.15,
a: 1.0,
};
const CARD: Color = Color {
r: 0.12,
g: 0.12,
b: 0.12,
a: 1.0,
};
const BORDER: Color = Color {
r: 0.30,
g: 0.30,
b: 0.30,
a: 1.0,
};
const DIM: Color = Color {
r: 0.55,
g: 0.55,
b: 0.55,
a: 1.0,
};
const ACCENT: Color = Color {
r: 0.30,
g: 0.62,
b: 0.95,
a: 1.0,
};
const WHITE: Color = Color {
r: 0.92,
g: 0.92,
b: 0.92,
a: 1.0,
};
fn badge<'a>(label: String) -> Element<'a, Message> {
container(text(label).size(11).color(WHITE))
.padding([2, 8])
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color {
r: 0.20,
g: 0.34,
b: 0.52,
a: 1.0,
})),
border: Border {
radius: 4.0.into(),
..Default::default()
},
..Default::default()
})
.into()
}
fn toggle_button<'a>(id: &str, disabled: bool) -> Element<'a, Message> {
// Label shows the action the click performs.
let (label, on, off) = if disabled {
("Enable", Color { r: 0.18, g: 0.5, b: 0.25, a: 1.0 }, Color { r: 0.22, g: 0.6, b: 0.3, a: 1.0 })
} else {
("Disable", Color { r: 0.4, g: 0.22, b: 0.22, a: 1.0 }, Color { r: 0.55, g: 0.28, b: 0.28, a: 1.0 })
};
let want_enabled = disabled; // clicking flips the state
let id_owned = id.to_string();
button(text(label).size(12).color(WHITE))
.padding([3, 12])
.on_press(Message::SetPluginEnabled(id_owned, want_enabled))
.style(move |_: &Theme, status| {
let bg = match status {
button::Status::Hovered | button::Status::Pressed => off,
_ => on,
};
button::Style {
background: Some(Background::Color(bg)),
text_color: WHITE,
border: Border { radius: 4.0.into(), ..Default::default() },
..Default::default()
}
})
.into()
}
/// Coloured status pill for a discovered external package.
fn status_badge<'a>(label: &str, color: Color) -> Element<'a, Message> {
container(text(label.to_string()).size(11).color(WHITE))
.padding([2, 8])
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(color)),
border: Border {
radius: 4.0.into(),
..Default::default()
},
..Default::default()
})
.into()
}
fn external_card<'a>(p: &ExternalPlugin, loaded: bool, disabled: bool) -> Element<'a, Message> {
let (status, color) = if loaded && disabled {
("Disabled", Color { r: 0.45, g: 0.45, b: 0.45, a: 1.0 })
} else if loaded {
("Loaded", Color { r: 0.2, g: 0.5, b: 0.3, a: 1.0 })
} else if !p.api_compatible() {
("API incompatible", Color { r: 0.55, g: 0.28, b: 0.28, a: 1.0 })
} else if !p.lib_present {
("No library", Color { r: 0.5, g: 0.42, b: 0.2, a: 1.0 })
} else {
("Restart to load", Color { r: 0.5, g: 0.42, b: 0.2, a: 1.0 })
};
let mut header = row![
text(p.name.clone()).size(15).color(WHITE),
Space::new().width(8),
badge(format!("v{}", p.version)),
Space::new().width(8),
badge(format!("API {}", p.api_version)),
Space::new().width(Fill),
status_badge(status, color),
]
.align_y(iced::Center);
// A loaded plugin can be turned off (drops its ribbon tab + dispatch).
if loaded {
header = header.push(Space::new().width(10));
header = header.push(toggle_button(&p.id, disabled));
}
header = header.push(Space::new().width(6));
header = header.push(pill_button(
"Uninstall",
Message::PluginUninstall(p.id.clone()),
Color { r: 0.4, g: 0.25, b: 0.25, a: 1.0 },
));
let id_line = text(p.id.clone()).size(11).color(ACCENT);
let mut body = column![header, id_line].spacing(5);
if !p.description.is_empty() {
body = body.push(text(p.description.clone()).size(12).color(DIM));
}
if !p.command_prefixes.is_empty() {
body = body.push(
text(format!("Commands: {}", p.command_prefixes.join(", ")))
.size(11)
.color(DIM),
);
}
container(body.padding([12, 14]))
.width(Fill)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(CARD)),
border: Border { color: BORDER, width: 1.0, radius: 6.0.into() },
..Default::default()
})
.into()
}
fn pill_button<'a>(label: &str, msg: Message, bg: Color) -> Element<'a, Message> {
button(text(label.to_string()).size(12).color(WHITE))
.padding([4, 12])
.on_press(msg)
.style(move |_: &Theme, status| {
let c = if matches!(status, button::Status::Hovered | button::Status::Pressed) {
Color { r: bg.r + 0.08, g: bg.g + 0.08, b: bg.b + 0.08, a: 1.0 }
} else {
bg
};
button::Style {
background: Some(Background::Color(c)),
text_color: WHITE,
border: Border { radius: 4.0.into(), ..Default::default() },
..Default::default()
}
})
.into()
}
/// Square icon variant of [`pill_button`] for glyph-free actions (e.g. remove).
fn pill_icon_button<'a>(icon: &'static [u8], msg: Message, bg: Color) -> Element<'a, Message> {
button(crate::ui::icons::tinted(icon, 11.0, WHITE))
.padding([5, 9])
.on_press(msg)
.style(move |_: &Theme, status| {
let c = if matches!(status, button::Status::Hovered | button::Status::Pressed) {
Color { r: bg.r + 0.08, g: bg.g + 0.08, b: bg.b + 0.08, a: 1.0 }
} else {
bg
};
button::Style {
background: Some(Background::Color(c)),
text_color: WHITE,
border: Border { radius: 4.0.into(), ..Default::default() },
..Default::default()
}
})
.into()
}
const GREEN: Color = Color { r: 0.2, g: 0.45, b: 0.28, a: 1.0 };
const RED: Color = Color { r: 0.4, g: 0.25, b: 0.25, a: 1.0 };
/// Release dropdown + Install (+ optional unlink) for one repo.
fn install_controls<'a>(
repo: &str,
tags: Vec<String>,
selected: Option<String>,
removable: bool,
) -> Element<'a, Message> {
let repo_s = repo.to_string();
let picker: Element<'_, Message> = if tags.is_empty() {
text("no releases").size(11).color(DIM).into()
} else {
let r = repo_s.clone();
pick_list(tags, selected, move |tag| {
Message::PluginReleaseSelect(r.clone(), tag)
})
.text_size(12)
.into()
};
let mut controls = row![
picker,
Space::new().width(8),
pill_button("Install", Message::PluginInstall(repo_s.clone()), GREEN),
]
.align_y(iced::Center)
.spacing(4);
if removable {
controls = controls.push(Space::new().width(6));
controls = controls.push(pill_icon_button(
crate::ui::icons::CLOSE,
Message::PluginRepoRemove(repo_s),
RED,
));
}
controls.into()
}
fn market_card<'a>(body: iced::widget::Column<'a, Message>) -> Element<'a, Message> {
container(body.spacing(4).padding([10, 12]))
.width(Fill)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(CARD)),
border: Border { color: BORDER, width: 1.0, radius: 6.0.into() },
..Default::default()
})
.into()
}
fn marketplace_section<'a>(m: &MarketView) -> Element<'a, Message> {
let mut col = column![text("Available plugins").size(13).color(ACCENT)].spacing(6);
// Curated registry entries (from the OpenCADStudio repo).
for e in m.registry {
let tags = m.release_tags.get(&e.repo).cloned().unwrap_or_default();
let selected = m.selected_tag.get(&e.repo).cloned();
let header = row![
text(e.name.clone()).size(14).color(WHITE),
Space::new().width(Fill),
install_controls(&e.repo, tags, selected, false),
]
.align_y(iced::Center);
let mut body = column![header, text(e.repo.clone()).size(11).color(ACCENT)];
if !e.description.is_empty() {
body = body.push(text(e.description.clone()).size(12).color(DIM));
}
col = col.push(market_card(body));
}
// Manual: link any repo by owner/repo.
col = col.push(Space::new().height(6));
col = col.push(text("Add a repository").size(12).color(DIM));
col = col.push(
row![
text_input("owner/repo", m.input)
.on_input(Message::PluginRepoInput)
.on_submit(Message::PluginRepoAdd)
.size(13)
.width(Fill),
Space::new().width(8),
pill_button("Add", Message::PluginRepoAdd, Color { r: 0.2, g: 0.4, b: 0.62, a: 1.0 }),
]
.align_y(iced::Center),
);
for repo in m.repos {
let tags = m.release_tags.get(repo).cloned().unwrap_or_default();
let selected = m.selected_tag.get(repo).cloned();
let header = row![
text(repo.clone()).size(13).color(WHITE),
Space::new().width(Fill),
install_controls(repo, tags, selected, true),
]
.align_y(iced::Center);
col = col.push(market_card(column![header]));
}
if !m.status.is_empty() {
col = col.push(text(m.status.to_string()).size(11).color(DIM));
}
col.into()
}
pub fn view_window<'a>(
disabled: &FxHashSet<String>,
externals: &[ExternalPlugin],
loaded: &FxHashSet<String>,
market: MarketView,
) -> Element<'a, Message> {
let title = text("Plugins").size(20).color(WHITE);
let subtitle = text("Add-ons load from the plugins folder. Install from a repository below.")
.size(12)
.color(DIM);
let mut list = column![].spacing(10);
// Installed external packages (from the plugins folder).
if externals.is_empty() {
list = list.push(text("No plugins installed yet.").size(13).color(DIM));
} else {
list = list.push(text("Installed").size(13).color(ACCENT));
for p in externals {
list = list.push(external_card(
p,
loaded.contains(&p.id),
disabled.contains(&p.id),
));
}
}
// Marketplace: install from a linked repository's releases.
list = list.push(Space::new().height(14));
list = list.push(marketplace_section(&market));
let body: Element<'_, Message> = scrollable(list.width(Fill)).height(Fill).into();
container(
column![title, subtitle, Space::new().height(12), body]
.spacing(4)
.padding(20)
.width(Fill)
.height(Fill),
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)),
..Default::default()
})
.width(Fill)
.height(Fill)
.into()
}

192
src/ui/window/shortcuts.rs Normal file
View file

@ -0,0 +1,192 @@
//! Keyboard Shortcuts Reference window — fills the entire OS window.
use crate::app::Message;
use iced::widget::{column, container, row, scrollable, text, Space};
use iced::{Background, Color, Element, Fill, Theme};
const TB: Color = Color {
r: 0.13,
g: 0.13,
b: 0.13,
a: 1.0,
};
const 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 DIM: 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 KEY: Color = Color {
r: 0.40,
g: 0.70,
b: 1.00,
a: 1.0,
};
fn hdivider<'a>() -> Element<'a, Message> {
container(Space::new().width(Fill).height(1))
.width(Fill)
.height(1)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BORDER)),
..Default::default()
})
.into()
}
fn shortcut_row<'a>(key: &'static str, action: &'static str) -> Element<'a, Message> {
row![
text(key)
.size(11)
.color(KEY)
.font(iced::Font::MONOSPACE)
.width(160),
text(action).size(11),
]
.spacing(8)
.align_y(iced::Center)
.padding([2, 0])
.into()
}
fn section<'a>(title: &'static str) -> Element<'a, Message> {
container(text(title).size(11).color(DIM))
.padding(iced::Padding {
top: 6.0,
right: 0.0,
bottom: 2.0,
left: 0.0,
})
.into()
}
pub fn view_window<'a>(
overrides: &'a rustc_hash::FxHashMap<String, String>,
) -> Element<'a, Message> {
// ── Toolbar ───────────────────────────────────────────────────────────
let toolbar = container(
row![
text("Type SHORTCUTS SET <key> <cmd> to add custom shortcuts.")
.size(10)
.color(DIM),
]
.align_y(iced::Center),
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(TB)),
..Default::default()
})
.width(Fill)
.padding([5, 10]);
// ── Shortcut entries ──────────────────────────────────────────────────
let mut rows: Vec<Element<'_, Message>> = vec![
section("── Function Keys ──────────────────────────────────────"),
shortcut_row("F3", "Toggle Object Snap"),
shortcut_row("F7", "Toggle Grid"),
shortcut_row("F8", "Toggle Ortho"),
shortcut_row("F9", "Toggle Grid Snap"),
shortcut_row("F10", "Toggle Polar Tracking"),
shortcut_row("F11", "Toggle Object Snap Tracking"),
shortcut_row("F12", "Toggle Dynamic Input"),
section("── Ctrl Shortcuts ──────────────────────────────────────"),
shortcut_row("Ctrl+N", "New Drawing"),
shortcut_row("Ctrl+O", "Open File"),
shortcut_row("Ctrl+S", "Save"),
shortcut_row("Ctrl+Shift+S", "Save As"),
shortcut_row("Ctrl+Z", "Undo"),
shortcut_row("Ctrl+Shift+Z / Ctrl+Y", "Redo"),
shortcut_row("Ctrl+C", "Copy to Clipboard"),
shortcut_row("Ctrl+X", "Cut to Clipboard"),
shortcut_row("Ctrl+V", "Paste from Clipboard"),
section("── Other Keys ──────────────────────────────────────────"),
shortcut_row("Enter / Space", "Finalize command / Repeat last"),
shortcut_row("Escape", "Cancel active command"),
shortcut_row("Delete", "Delete selected entities"),
shortcut_row("↑ / ↓", "Command history navigation"),
];
// Custom overrides section
rows.push(section(
"── Custom Overrides (SHORTCUTS SET) ──────────────────",
));
if overrides.is_empty() {
rows.push(
text(" (none — use: SHORTCUTS SET <key> <command>)")
.size(11)
.color(DIM)
.into(),
);
} else {
let mut sorted: Vec<_> = overrides.iter().collect();
sorted.sort_by_key(|(k, _)| k.as_str());
for (key, cmd) in sorted {
rows.push(
row![
text(key.as_str())
.size(11)
.color(KEY)
.font(iced::Font::MONOSPACE)
.width(160),
text(cmd.as_str()).size(11),
]
.spacing(8)
.align_y(iced::Center)
.padding([2, 0])
.into(),
);
}
}
// ── Section headers styled separately ────────────────────────────────
let content = scrollable(column(rows).spacing(3).padding([12, 16]))
.width(Fill)
.height(Fill);
// ── Header row with accent ────────────────────────────────────────────
let header = container(
row![
text("Key").size(10).color(ACCENT).width(160),
text("Action").size(10).color(ACCENT),
]
.spacing(8)
.padding([4, 16]),
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color {
r: 0.13,
g: 0.13,
b: 0.18,
a: 1.0,
})),
..Default::default()
})
.width(Fill);
container(column![toolbar, hdivider(), header, hdivider(), content].spacing(0))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)),
..Default::default()
})
.width(Fill)
.height(Fill)
.into()
}

View file

@ -0,0 +1,304 @@
use crate::app::Message;
use iced::widget::{button, column, container, row, scrollable, text, Space};
use iced::{Background, Border, Color, Element, Fill, Theme};
const BG: Color = Color {
r: 0.15,
g: 0.15,
b: 0.15,
a: 1.0,
};
const PANEL: Color = Color {
r: 0.12,
g: 0.12,
b: 0.12,
a: 1.0,
};
const BORDER: Color = Color {
r: 0.30,
g: 0.30,
b: 0.30,
a: 1.0,
};
const DIM: 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 WHITE: Color = Color {
r: 0.92,
g: 0.92,
b: 0.92,
a: 1.0,
};
/// Renders one of the two "Installed" / "Latest" cards. The `highlight`
/// flag tints the border + label with the accent colour, making the new
/// version the visual anchor of the row.
fn version_card<'a>(label: &'static str, value: String, highlight: bool) -> Element<'a, Message> {
let label_color = if highlight { ACCENT } else { DIM };
let value_color = WHITE;
let border_color = if highlight { ACCENT } else { BORDER };
let bg = if highlight {
Color { r: 0.10, g: 0.16, b: 0.22, a: 1.0 } // accent-tinted dark
} else {
PANEL
};
container(
column![
text(label).size(10).color(label_color),
text(value).size(20).color(value_color),
]
.spacing(4)
.align_x(iced::Center),
)
.width(Fill)
.padding(iced::Padding {
top: 14.0,
right: 12.0,
bottom: 14.0,
left: 12.0,
})
.align_x(iced::Center)
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(bg)),
border: Border {
color: border_color,
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
})
.into()
}
/// Light-weight renderer for a single line of GitHub-style release-notes
/// markdown. Recognises:
/// * `## Heading` → bold accent line
/// * `### Heading` → smaller bold line
/// * `- bullet` → indented bullet text
/// * `**bold**` runs and `` `code` `` runs (rendered tonally, not styled
/// differently — iced's text widget has no inline run styling).
/// Anything else is plain body text. Strips the markdown markers so the
/// dialog reads cleanly even if the user has a Patreon-formatted note.
fn render_notes_line<'a>(raw: &str) -> Element<'a, Message> {
let trimmed = raw.trim_end();
if trimmed.is_empty() {
return Space::new()
.height(iced::Length::Fixed(6.0))
.into();
}
if let Some(rest) = trimmed.strip_prefix("## ") {
return text(strip_inline_md(rest))
.size(13)
.color(ACCENT)
.into();
}
if let Some(rest) = trimmed.strip_prefix("### ") {
return text(strip_inline_md(rest))
.size(12)
.color(WHITE)
.into();
}
if let Some(rest) = trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* ")) {
return row![
container(crate::ui::icons::tinted(crate::ui::icons::DOT, 5.0, DIM)).width(14),
text(strip_inline_md(rest)).size(11).color(WHITE),
]
.spacing(4)
.into();
}
text(strip_inline_md(trimmed)).size(11).color(WHITE).into()
}
/// Drop `**…**` and `` `…` `` markers without preserving emphasis (iced 0.14
/// Text widgets style the whole string uniformly). Keeps the inner text.
fn strip_inline_md(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '*' && chars.peek() == Some(&'*') {
chars.next();
continue;
}
if c == '`' {
continue;
}
out.push(c);
}
out
}
pub fn view_window<'a>(latest: &'a str, body: &'a str) -> Element<'a, Message> {
let header = container(
column![
text("New Release Available").size(20).color(ACCENT),
text("A newer Open CAD Studio version is published on GitHub.")
.size(11)
.color(DIM),
]
.spacing(4)
.align_x(iced::Center),
)
.width(Fill)
.padding(iced::Padding {
top: 14.0,
right: 0.0,
bottom: 6.0,
left: 0.0,
})
.align_x(iced::Center);
// Two side-by-side version cards (Installed → Latest) with the latest
// one accent-tinted to draw the eye. Replaces the previous label/value
// row layout. The arrow between them is purely decorative.
let installed = version_card(
"Installed",
format!("v{}", env!("CARGO_PKG_VERSION")),
false,
);
let latest_card = version_card("Latest", format!("v{}", latest), true);
let arrow = container(crate::ui::icons::tinted(
crate::ui::icons::ARROW_LONG_RIGHT,
20.0,
DIM,
))
.width(iced::Length::Fixed(32.0))
.height(Fill)
.align_x(iced::Center)
.align_y(iced::Center);
let info_block = row![installed, arrow, latest_card]
.spacing(0)
.align_y(iced::Center)
.width(Fill);
let later_btn = button(text("Later").size(11))
.on_press(Message::UpdateNoticeClose)
.style(|_: &Theme, st| button::Style {
background: Some(Background::Color(match st {
button::Status::Hovered | button::Status::Pressed => Color {
r: 0.22,
g: 0.22,
b: 0.22,
a: 1.0,
},
_ => Color {
r: 0.18,
g: 0.18,
b: 0.18,
a: 1.0,
},
})),
text_color: WHITE,
border: Border {
color: BORDER,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
})
.padding([6, 16]);
let open_btn = button(text("Open Release Page").size(11))
.on_press(Message::UpdateNoticeOpenRelease)
.style(|_: &Theme, st| button::Style {
background: Some(Background::Color(match st {
button::Status::Hovered | button::Status::Pressed => Color {
r: 0.20,
g: 0.42,
b: 0.72,
a: 1.0,
},
_ => ACCENT,
})),
text_color: WHITE,
border: Border {
radius: 4.0.into(),
..Default::default()
},
..Default::default()
})
.padding([6, 16]);
let footer = row![Space::new().width(Fill), later_btn, open_btn]
.spacing(8)
.align_y(iced::Center)
.padding(iced::Padding {
top: 14.0,
right: 0.0,
bottom: 0.0,
left: 0.0,
});
// Release notes panel. Rendered as a light-markdown column inside a
// bordered scrollable so long bodies stay contained and don't
// explode the window. Empty body → "No release notes provided."
let notes_heading = container(text("What's new").size(11).color(DIM))
.padding(iced::Padding {
top: 10.0,
right: 0.0,
bottom: 4.0,
left: 0.0,
});
let notes_body: Element<'a, Message> = if body.trim().is_empty() {
text("No release notes provided.")
.size(11)
.color(DIM)
.into()
} else {
let mut col = column![].spacing(4);
for line in body.lines() {
col = col.push(render_notes_line(line));
}
scrollable(container(col).padding([10, 14])).height(Fill).into()
};
let notes_block = container(notes_body)
.width(Fill)
.height(Fill)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(PANEL)),
border: Border {
color: BORDER,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
});
// Wrap notes_block in a Fill-height container outside the column so it
// greedily claims every pixel left over after the fixed-height rows
// (header, version cards, heading, footer). Without this iced lets the
// notes panel shrink to its content height, leaving a gap above the
// footer.
let notes_fill = container(notes_block)
.width(Fill)
.height(Fill);
container(
column![header, info_block, notes_heading, notes_fill, footer]
.spacing(0)
.height(Fill)
.padding(iced::Padding {
top: 0.0,
right: 20.0,
bottom: 20.0,
left: 20.0,
}),
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)),
..Default::default()
})
.width(Fill)
.height(Fill)
.into()
}