forked from 1b-agent/opencadstudio-upstream
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 <[email protected]>
This commit is contained in:
1 parent
9b8ee94c0c
commit
a502379048
17 files changed
+29
-27
No files matched your search
@@ -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()
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user