Modernize the UI and isolate fixture tests.

This commit is contained in:
2026-07-18 18:54:39 +02:00
parent 897e116db7
commit f96e6f1df4
23 changed files with 744 additions and 398 deletions

View File

@@ -1,15 +1,104 @@
use iced::widget::text::Shaping;
use iced::widget::{Space, button, checkbox, column, container, pick_list, row, text, text_input};
use iced::{Alignment, Background, Color, Element, Length, Theme};
use iced::widget::{
Container, button, checkbox, column, container, pick_list, row, text, text_editor, text_input,
};
use iced::{Alignment, Background, Border, Color, Element, Length, Shadow, Theme, Vector};
/// Standard form field label color.
pub const LABEL_COLOR: Color = Color::from_rgb(0.65, 0.68, 0.75);
const _FIELD_BG: Color = Color::from_rgb(0.15, 0.16, 0.20);
pub const SECTION_COLOR: Color = Color::from_rgb(0.50, 0.52, 0.58);
const DANGER_BG: Color = Color::from_rgb(0.60, 0.15, 0.15);
const DANGER_HOVER: Color = Color::from_rgb(0.70, 0.20, 0.20);
const PRIMARY_BG: Color = Color::from_rgb(0.20, 0.40, 0.70);
const PRIMARY_HOVER: Color = Color::from_rgb(0.25, 0.48, 0.80);
pub const LABEL_COLOR: Color = rgb8(0xB5, 0xBA, 0xC4);
pub const SECTION_COLOR: Color = rgb8(0x9D, 0xA5, 0xB4);
const FIELD_BG: Color = rgb8(0x24, 0x24, 0x26);
const SURFACE_BG: Color = rgb8(0x25, 0x25, 0x26);
const BORDER_COLOR: Color = rgb8(0x3C, 0x3C, 0x3C);
const FOCUS_COLOR: Color = rgb8(0x00, 0x7F, 0xD4);
const DANGER_BG: Color = rgb8(0xB6, 0x23, 0x24);
const DANGER_HOVER: Color = rgb8(0xCF, 0x2F, 0x30);
const PRIMARY_BG: Color = rgb8(0x0E, 0x63, 0x9C);
const PRIMARY_HOVER: Color = rgb8(0x11, 0x77, 0xBB);
const SECONDARY_BG: Color = rgb8(0x2D, 0x2D, 0x30);
const SECONDARY_HOVER: Color = rgb8(0x3A, 0x3D, 0x41);
const fn rgb8(red: u8, green: u8, blue: u8) -> Color {
Color {
r: red as f32 / 255.0,
g: green as f32 / 255.0,
b: blue as f32 / 255.0,
a: 1.0,
}
}
/// Application-wide VS Code-like palette for native Iced controls.
pub fn app_theme() -> Theme {
Theme::custom(
"bDS".to_string(),
iced::theme::Palette {
background: Color::from_rgb8(0x1E, 0x1E, 0x1E),
text: Color::from_rgb8(0xCC, 0xCC, 0xCC),
primary: PRIMARY_BG,
success: Color::from_rgb8(0x2E, 0x7D, 0x32),
danger: DANGER_BG,
},
)
}
pub fn field_style(_theme: &Theme, status: text_input::Status) -> text_input::Style {
let border_color = match status {
text_input::Status::Focused => FOCUS_COLOR,
text_input::Status::Hovered => Color::from_rgb8(0x5A, 0x5A, 0x5A),
_ => BORDER_COLOR,
};
text_input::Style {
background: Background::Color(FIELD_BG),
border: Border {
color: border_color,
width: 1.0,
radius: 6.0.into(),
},
icon: SECTION_COLOR,
placeholder: Color::from_rgb8(0x7D, 0x7D, 0x7D),
value: Color::from_rgb8(0xE4, 0xE4, 0xE4),
selection: Color::from_rgba8(0x26, 0x4F, 0x78, 0.8),
}
}
/// Multi-line editor style matching the standard form fields.
pub fn text_editor_style(_theme: &Theme, status: text_editor::Status) -> text_editor::Style {
let border_color = match status {
text_editor::Status::Focused => FOCUS_COLOR,
text_editor::Status::Hovered => Color::from_rgb8(0x5A, 0x5A, 0x5A),
_ => BORDER_COLOR,
};
text_editor::Style {
background: Background::Color(FIELD_BG),
border: Border {
color: border_color,
width: 1.0,
radius: 6.0.into(),
},
icon: SECTION_COLOR,
placeholder: Color::from_rgb8(0x7D, 0x7D, 0x7D),
value: Color::from_rgb8(0xE4, 0xE4, 0xE4),
selection: Color::from_rgba8(0x26, 0x4F, 0x78, 0.8),
}
}
fn select_style(_theme: &Theme, status: pick_list::Status) -> pick_list::Style {
let border_color = match status {
pick_list::Status::Hovered | pick_list::Status::Opened => FOCUS_COLOR,
pick_list::Status::Active => BORDER_COLOR,
};
pick_list::Style {
text_color: Color::from_rgb8(0xE4, 0xE4, 0xE4),
placeholder_color: Color::from_rgb8(0x7D, 0x7D, 0x7D),
handle_color: SECTION_COLOR,
background: Background::Color(FIELD_BG),
border: Border {
color: border_color,
width: 1.0,
radius: 6.0.into(),
},
}
}
/// A labeled text input field.
pub fn labeled_input<'a, Message: Clone + 'a>(
@@ -23,9 +112,15 @@ pub fn labeled_input<'a, Message: Clone + 'a>(
.size(12)
.color(LABEL_COLOR)
.shaping(Shaping::Advanced),
text_input(placeholder, value).on_input(on_change).size(14),
text_input(placeholder, value)
.on_input(on_change)
.size(14)
.padding([8, 10])
.width(Length::Fill)
.style(field_style),
]
.spacing(4)
.spacing(6)
.width(Length::Fill)
.into()
}
@@ -46,9 +141,13 @@ where
.size(12)
.color(LABEL_COLOR)
.shaping(Shaping::Advanced),
pick_list(list, selected.cloned(), on_select),
pick_list(list, selected.cloned(), on_select)
.padding([8, 10])
.width(Length::Fill)
.style(select_style),
]
.spacing(4)
.spacing(6)
.width(Length::Fill)
.into()
}
@@ -67,21 +166,11 @@ pub fn labeled_checkbox<'a, Message: Clone + 'a>(
/// A section header with optional separator line.
pub fn section_header<'a, Message: 'a>(label: &str) -> Element<'a, Message> {
column![
text(label.to_string())
.size(11)
.color(SECTION_COLOR)
.shaping(Shaping::Advanced),
container(Space::new(0, 0))
.width(Length::Fill)
.height(Length::Fixed(1.0))
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.25, 0.25, 0.30))),
..container::Style::default()
}),
]
.spacing(4)
.into()
text(label.to_string())
.size(12)
.color(SECTION_COLOR)
.shaping(Shaping::Advanced)
.into()
}
/// Primary action button style.
@@ -92,7 +181,7 @@ pub fn primary_button(theme: &Theme, status: button::Status) -> button::Style {
background: Some(Background::Color(PRIMARY_HOVER)),
text_color: Color::WHITE,
border: iced::Border {
radius: 4.0.into(),
radius: 6.0.into(),
..iced::Border::default()
},
..button::Style::default()
@@ -101,7 +190,7 @@ pub fn primary_button(theme: &Theme, status: button::Status) -> button::Style {
background: Some(Background::Color(PRIMARY_BG)),
text_color: Color::WHITE,
border: iced::Border {
radius: 4.0.into(),
radius: 6.0.into(),
..iced::Border::default()
},
..button::Style::default()
@@ -117,7 +206,7 @@ pub fn danger_button(theme: &Theme, status: button::Status) -> button::Style {
background: Some(Background::Color(DANGER_HOVER)),
text_color: Color::WHITE,
border: iced::Border {
radius: 4.0.into(),
radius: 6.0.into(),
..iced::Border::default()
},
..button::Style::default()
@@ -126,7 +215,7 @@ pub fn danger_button(theme: &Theme, status: button::Status) -> button::Style {
background: Some(Background::Color(DANGER_BG)),
text_color: Color::WHITE,
border: iced::Border {
radius: 4.0.into(),
radius: 6.0.into(),
..iced::Border::default()
},
..button::Style::default()
@@ -134,6 +223,66 @@ pub fn danger_button(theme: &Theme, status: button::Status) -> button::Style {
}
}
/// Secondary editor action button style.
pub fn secondary_button(_theme: &Theme, status: button::Status) -> button::Style {
let background = match status {
button::Status::Hovered => SECONDARY_HOVER,
button::Status::Disabled => SECONDARY_BG.scale_alpha(0.45),
_ => SECONDARY_BG,
};
button::Style {
background: Some(Background::Color(background)),
text_color: if matches!(status, button::Status::Disabled) {
LABEL_COLOR.scale_alpha(0.55)
} else {
Color::from_rgb8(0xE4, 0xE4, 0xE4)
},
border: Border {
color: BORDER_COLOR,
width: 1.0,
radius: 6.0.into(),
},
..button::Style::default()
}
}
/// Hoverable disclosure row style for collapsible editor sections.
pub fn disclosure_button(_theme: &Theme, status: button::Status) -> button::Style {
button::Style {
background: match status {
button::Status::Hovered => Some(Background::Color(SECONDARY_BG)),
_ => None,
},
text_color: SECTION_COLOR,
border: Border {
radius: 6.0.into(),
..Border::default()
},
..button::Style::default()
}
}
/// Raised surface used to group editor metadata and controls.
pub fn card<'a, Message: 'a>(content: impl Into<Element<'a, Message>>) -> Container<'a, Message> {
container(content)
.padding(16)
.width(Length::Fill)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(SURFACE_BG)),
border: Border {
color: Color::from_rgb8(0x31, 0x31, 0x33),
width: 1.0,
radius: 10.0.into(),
},
shadow: Shadow {
color: Color::from_rgba(0.0, 0.0, 0.0, 0.28),
offset: Vector::new(0.0, 4.0),
blur_radius: 16.0,
},
..container::Style::default()
})
}
/// Toolbar-style row with right-aligned actions.
pub fn toolbar<'a, Message: 'a>(
left: Vec<Element<'a, Message>>,
@@ -151,12 +300,29 @@ pub fn toolbar<'a, Message: 'a>(
.align_y(Alignment::Center)
.wrap();
row![left_row, right_row]
.padding(8)
.spacing(8)
.align_y(Alignment::Center)
.width(Length::Fill)
.into()
container(
row![left_row, right_row]
.spacing(10)
.align_y(Alignment::Center)
.width(Length::Fill),
)
.padding(10)
.width(Length::Fill)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(SURFACE_BG)),
border: Border {
color: Color::from_rgb8(0x31, 0x31, 0x33),
width: 1.0,
radius: 10.0.into(),
},
shadow: Shadow {
color: Color::from_rgba(0.0, 0.0, 0.0, 0.2),
offset: Vector::new(0.0, 2.0),
blur_radius: 10.0,
},
..container::Style::default()
})
.into()
}
/// Date display (read-only, locale-formatted).
@@ -184,3 +350,31 @@ fn format_timestamp(ms: i64) -> String {
let min = ((secs % 3600) / 60) as u32;
format!("{y}-{m:02}-{d:02} {h:02}:{min:02}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn editor_controls_have_distinct_hover_and_focus_states() {
let theme = app_theme();
assert_ne!(
primary_button(&theme, button::Status::Active).background,
primary_button(&theme, button::Status::Hovered).background
);
assert_ne!(
field_style(&theme, text_input::Status::Active).border.color,
field_style(&theme, text_input::Status::Focused)
.border
.color
);
assert_ne!(
text_editor_style(&theme, text_editor::Status::Active)
.border
.color,
text_editor_style(&theme, text_editor::Status::Focused)
.border
.color
);
}
}