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

@@ -7,6 +7,11 @@ This project has an allium spec in the folder specs/ - use it to verify behaviou
Invariants and behaviours in the allium spec should be covered by unit tests of the application code, to make sure the spec is followed. Invariants and behaviours in the allium spec should be covered by unit tests of the application code, to make sure the spec is followed.
## UI styling
- Before implementing or changing UI, read and follow `docs/UI_STYLE_GUIDE.md`.
- Reuse the shared styling primitives described there so new sidebars and editor areas remain consistent with the post editor.
## Plan Mode ## Plan Mode
- Make the plan extremely concise. Sacrifice grammar for the sake of concision. - Make the plan extremely concise. Sacrifice grammar for the sake of concision.
@@ -45,4 +50,3 @@ Invariants and behaviours in the allium spec should be covered by unit tests of
- when creating rust source code, always follow what the allium spec is saying for that part - when creating rust source code, always follow what the allium spec is saying for that part
- when tending the allium spec, make sure you validate the spec with the installed command line utility - when tending the allium spec, make sure you validate the spec with the installed command line utility
- don't be lazy. don't defer or skip implementations just because you have to write code for that, that is ridiculous. if the spec says something has to be there, it has to be there. - don't be lazy. don't defer or skip implementations just because you have to write code for that, that is ridiculous. if the spec says something has to be there, it has to be there.

View File

@@ -1,9 +1,11 @@
//! Verify that the Rust Diesel models read the compatibility fixture produced by bDS. //! Verify that the Rust Diesel models read the compatibility fixture produced by bDS.
mod support;
use std::collections::HashSet; use std::collections::HashSet;
use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use bds_core::db::Database;
use bds_core::db::queries::{ use bds_core::db::queries::{
media, post, post_link, post_media, post_translation, project, script, setting, tag, template, media, post, post_link, post_media, post_translation, project, script, setting, tag, template,
}; };
@@ -17,11 +19,21 @@ const GHOSTTY_ID: &str = "6745981d-da41-4cfd-80ec-95ad339acf6f";
const CMUX_ID: &str = "2665bfaa-8251-468d-a710-a4cf34dd81e2"; const CMUX_ID: &str = "2665bfaa-8251-468d-a710-a4cf34dd81e2";
const SPIDER_ID: &str = "eb0cf9d7-6fbd-4b74-9be3-759d6e16f240"; const SPIDER_ID: &str = "eb0cf9d7-6fbd-4b74-9be3-759d6e16f240";
fn fixture_db() -> Database { fn fixture_db() -> support::FixtureDatabase {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) support::fixture_database()
.join("../../fixtures/compatibility-projects/rfc1437-sample/bds.db"); }
assert!(path.exists(), "fixture DB not found at {}", path.display());
Database::open(&path).unwrap() #[test]
fn fixture_database_files_are_not_modified_by_tests() {
let directory = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/compatibility-projects/rfc1437-sample");
let paths = ["bds.db", "bds.db-shm", "bds.db-wal"].map(|name| directory.join(name));
let before = paths.each_ref().map(|path| fs::read(path).unwrap());
drop(fixture_db());
let after = paths.each_ref().map(|path| fs::read(path).unwrap());
assert_eq!(after, before);
} }
#[test] #[test]

View File

@@ -1,7 +1,8 @@
//! Executable checks for storage-related Allium claims. //! Executable checks for storage-related Allium claims.
mod support;
use std::collections::HashSet; use std::collections::HashSet;
use std::path::PathBuf;
use bds_core::db::Database; use bds_core::db::Database;
use bds_core::db::queries::{ use bds_core::db::queries::{
@@ -13,12 +14,8 @@ use bds_core::model::{
}; };
use diesel::prelude::*; use diesel::prelude::*;
fn fixture_db() -> Database { fn fixture_db() -> support::FixtureDatabase {
Database::open( support::fixture_database()
&PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/compatibility-projects/rfc1437-sample/bds.db"),
)
.unwrap()
} }
fn memory_db() -> Database { fn memory_db() -> Database {

View File

@@ -0,0 +1,34 @@
use std::fs;
use std::ops::Deref;
use std::path::PathBuf;
use bds_core::db::Database;
use tempfile::TempDir;
pub struct FixtureDatabase {
database: Database,
_directory: TempDir,
}
impl Deref for FixtureDatabase {
type Target = Database;
fn deref(&self) -> &Self::Target {
&self.database
}
}
pub fn fixture_database() -> FixtureDatabase {
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/compatibility-projects/rfc1437-sample");
let directory = TempDir::new().unwrap();
for name in ["bds.db", "bds.db-shm", "bds.db-wal"] {
fs::copy(source.join(name), directory.path().join(name)).unwrap();
}
FixtureDatabase {
database: Database::open(&directory.path().join("bds.db")).unwrap(),
_directory: directory,
}
}

View File

@@ -10,7 +10,7 @@ use iced::advanced::{Clipboard, Shell};
use iced::event::Status; use iced::event::Status;
use iced::keyboard; use iced::keyboard;
use iced::mouse; use iced::mouse;
use iced::{Color, Element, Event, Length, Pixels, Point, Rectangle, Size, Theme}; use iced::{Color, Element, Event, Length, Pixels, Point, Rectangle, Shadow, Size, Theme, Vector};
use crate::buffer::EditorBuffer; use crate::buffer::EditorBuffer;
use crate::highlight::Highlighter; use crate::highlight::Highlighter;
@@ -76,21 +76,33 @@ pub fn mono_metrics() -> &'static MonoMetrics {
}) })
} }
const GUTTER_WIDTH: f32 = 50.0; const GUTTER_WIDTH: f32 = 44.0;
const TEXT_PADDING: f32 = 12.0;
const FONT_SIZE: f32 = 14.0; const FONT_SIZE: f32 = 14.0;
const BG_COLOR: Color = Color::from_rgb(0.18, 0.20, 0.25); const TEXT_COLOR: Color = rgb8(0xD4, 0xD4, 0xD4);
const GUTTER_BG: Color = Color::from_rgb(0.15, 0.17, 0.21); const GUTTER_TEXT: Color = rgb8(0x85, 0x85, 0x85);
const TEXT_COLOR: Color = Color::from_rgb(0.85, 0.85, 0.85); const CURSOR_COLOR: Color = rgb8(0xAE, 0xAF, 0xAD);
const GUTTER_TEXT: Color = Color::from_rgb(0.45, 0.48, 0.55); const ACTIVE_LINE_NUM: Color = rgb8(0xC6, 0xC6, 0xC6);
const CURSOR_COLOR: Color = Color::from_rgb(0.9, 0.9, 0.2); const SELECTION_BG: Color = rgba8(0x26, 0x4F, 0x78, 0.85);
const ACTIVE_LINE_NUM: Color = Color::from_rgb(0.75, 0.78, 0.85);
const SELECTION_BG: Color = Color::from_rgba(0.26, 0.54, 0.79, 0.40);
const SCROLLBAR_WIDTH: f32 = 10.0; const SCROLLBAR_WIDTH: f32 = 10.0;
const SCROLLBAR_TRACK: Color = Color::from_rgba(0.25, 0.27, 0.32, 0.5); const SCROLLBAR_TRACK: Color = Color::TRANSPARENT;
const SCROLLBAR_THUMB: Color = Color::from_rgba(0.50, 0.53, 0.60, 0.7); const SCROLLBAR_THUMB: Color = rgba8(0x79, 0x79, 0x79, 0.45);
const SCROLLBAR_THUMB_HOVER: Color = Color::from_rgba(0.60, 0.63, 0.70, 0.9); const SCROLLBAR_THUMB_HOVER: Color = rgba8(0x79, 0x79, 0x79, 0.75);
const MIN_THUMB_HEIGHT: f32 = 20.0; const MIN_THUMB_HEIGHT: f32 = 20.0;
const fn rgb8(red: u8, green: u8, blue: u8) -> Color {
rgba8(red, green, blue, 1.0)
}
const fn rgba8(red: u8, green: u8, blue: u8, alpha: f32) -> Color {
Color {
r: red as f32 / 255.0,
g: green as f32 / 255.0,
b: blue as f32 / 255.0,
a: alpha,
}
}
fn committed_text_input(text: Option<&str>, is_command_shortcut: bool) -> Option<&str> { fn committed_text_input(text: Option<&str>, is_command_shortcut: bool) -> Option<&str> {
if is_command_shortcut { if is_command_shortcut {
return None; return None;
@@ -303,7 +315,7 @@ where
&self, &self,
tree: &widget::Tree, tree: &widget::Tree,
renderer: &mut Renderer, renderer: &mut Renderer,
_theme: &Theme, theme: &Theme,
_style: &renderer::Style, _style: &renderer::Style,
layout: Layout<'_>, layout: Layout<'_>,
_cursor: mouse::Cursor, _cursor: mouse::Cursor,
@@ -317,24 +329,33 @@ where
renderer.fill_quad( renderer.fill_quad(
renderer::Quad { renderer::Quad {
bounds, bounds,
border: iced::Border::default(), border: iced::Border {
shadow: iced::Shadow::default(), color: Color::from_rgb8(0x35, 0x35, 0x35),
width: 1.0,
radius: 8.0.into(),
}, },
BG_COLOR, shadow: Shadow {
color: Color::from_rgba(0.0, 0.0, 0.0, 0.32),
offset: Vector::new(0.0, 3.0),
blur_radius: 14.0,
},
},
theme.palette().background,
); );
// Gutter background // Subtle gutter divider; the shared background keeps the editor visually light.
let gutter_bounds = Rectangle {
width: GUTTER_WIDTH,
..bounds
};
renderer.fill_quad( renderer.fill_quad(
renderer::Quad { renderer::Quad {
bounds: gutter_bounds, bounds: Rectangle {
x: bounds.x + GUTTER_WIDTH,
y: bounds.y,
width: 1.0,
height: bounds.height,
},
border: iced::Border::default(), border: iced::Border::default(),
shadow: iced::Shadow::default(), shadow: iced::Shadow::default(),
}, },
GUTTER_BG, Color::from_rgb8(0x2B, 0x2B, 0x2B),
); );
let metrics = mono_metrics(); let metrics = mono_metrics();
@@ -349,7 +370,7 @@ where
let highlighted = self.highlighter.highlight_lines(&full_text, syntax); let highlighted = self.highlighter.highlight_lines(&full_text, syntax);
let font = iced::Font::MONOSPACE; let font = iced::Font::MONOSPACE;
let text_area_width = bounds.width - GUTTER_WIDTH - 8.0; let text_area_width = bounds.width - GUTTER_WIDTH - TEXT_PADDING;
let max_chars = if self.word_wrap && text_area_width > metrics.char_width { let max_chars = if self.word_wrap && text_area_width > metrics.char_width {
(text_area_width / metrics.char_width).floor() as usize (text_area_width / metrics.char_width).floor() as usize
} else { } else {
@@ -382,7 +403,7 @@ where
line_idx += 1; line_idx += 1;
} }
let text_x = bounds.x + GUTTER_WIDTH + 8.0; let text_x = bounds.x + GUTTER_WIDTH + TEXT_PADDING;
// Render visual rows // Render visual rows
for (vis_idx, &(line_idx, char_start, char_end, is_first)) in visual_rows.iter().enumerate() for (vis_idx, &(line_idx, char_start, char_end, is_first)) in visual_rows.iter().enumerate()
@@ -666,7 +687,7 @@ where
let state = tree.state.downcast_mut::<EditorState>(); let state = tree.state.downcast_mut::<EditorState>();
let bounds = layout.bounds(); let bounds = layout.bounds();
let metrics = mono_metrics(); let metrics = mono_metrics();
let text_area_width = bounds.width - GUTTER_WIDTH - 8.0; let text_area_width = bounds.width - GUTTER_WIDTH - TEXT_PADDING;
let cpl = if self.word_wrap && text_area_width > metrics.char_width { let cpl = if self.word_wrap && text_area_width > metrics.char_width {
(text_area_width / metrics.char_width).floor() as usize (text_area_width / metrics.char_width).floor() as usize
} else { } else {
@@ -752,8 +773,8 @@ where
if let Some(pos) = cursor.position_in(bounds) { if let Some(pos) = cursor.position_in(bounds) {
let mut buf = self.buffer.borrow_mut(); let mut buf = self.buffer.borrow_mut();
let vis_row = (pos.y / metrics.line_height) as usize; let vis_row = (pos.y / metrics.line_height) as usize;
let raw_col = let raw_col = ((pos.x - GUTTER_WIDTH - TEXT_PADDING).max(0.0)
((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize; / metrics.char_width) as usize;
let (line, char_off) = let (line, char_off) =
visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl) visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl)
.unwrap_or((buf.line_count().saturating_sub(1), 0)); .unwrap_or((buf.line_count().saturating_sub(1), 0));
@@ -792,8 +813,8 @@ where
if let Some(pos) = cursor.position_in(bounds) { if let Some(pos) = cursor.position_in(bounds) {
let mut buf = self.buffer.borrow_mut(); let mut buf = self.buffer.borrow_mut();
let vis_row = (pos.y / metrics.line_height) as usize; let vis_row = (pos.y / metrics.line_height) as usize;
let raw_col = let raw_col = ((pos.x - GUTTER_WIDTH - TEXT_PADDING).max(0.0)
((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize; / metrics.char_width) as usize;
let (line, char_off) = let (line, char_off) =
visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl) visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl)
.unwrap_or((buf.line_count().saturating_sub(1), 0)); .unwrap_or((buf.line_count().saturating_sub(1), 0));

View File

@@ -1,15 +1,104 @@
use iced::widget::text::Shaping; use iced::widget::text::Shaping;
use iced::widget::{Space, button, checkbox, column, container, pick_list, row, text, text_input}; use iced::widget::{
use iced::{Alignment, Background, Color, Element, Length, Theme}; 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. /// Standard form field label color.
pub const LABEL_COLOR: Color = Color::from_rgb(0.65, 0.68, 0.75); pub const LABEL_COLOR: Color = rgb8(0xB5, 0xBA, 0xC4);
const _FIELD_BG: Color = Color::from_rgb(0.15, 0.16, 0.20); pub const SECTION_COLOR: Color = rgb8(0x9D, 0xA5, 0xB4);
pub const SECTION_COLOR: Color = Color::from_rgb(0.50, 0.52, 0.58); const FIELD_BG: Color = rgb8(0x24, 0x24, 0x26);
const DANGER_BG: Color = Color::from_rgb(0.60, 0.15, 0.15); const SURFACE_BG: Color = rgb8(0x25, 0x25, 0x26);
const DANGER_HOVER: Color = Color::from_rgb(0.70, 0.20, 0.20); const BORDER_COLOR: Color = rgb8(0x3C, 0x3C, 0x3C);
const PRIMARY_BG: Color = Color::from_rgb(0.20, 0.40, 0.70); const FOCUS_COLOR: Color = rgb8(0x00, 0x7F, 0xD4);
const PRIMARY_HOVER: Color = Color::from_rgb(0.25, 0.48, 0.80); 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. /// A labeled text input field.
pub fn labeled_input<'a, Message: Clone + 'a>( pub fn labeled_input<'a, Message: Clone + 'a>(
@@ -23,9 +112,15 @@ pub fn labeled_input<'a, Message: Clone + 'a>(
.size(12) .size(12)
.color(LABEL_COLOR) .color(LABEL_COLOR)
.shaping(Shaping::Advanced), .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() .into()
} }
@@ -46,9 +141,13 @@ where
.size(12) .size(12)
.color(LABEL_COLOR) .color(LABEL_COLOR)
.shaping(Shaping::Advanced), .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() .into()
} }
@@ -67,20 +166,10 @@ pub fn labeled_checkbox<'a, Message: Clone + 'a>(
/// A section header with optional separator line. /// A section header with optional separator line.
pub fn section_header<'a, Message: 'a>(label: &str) -> Element<'a, Message> { pub fn section_header<'a, Message: 'a>(label: &str) -> Element<'a, Message> {
column![
text(label.to_string()) text(label.to_string())
.size(11) .size(12)
.color(SECTION_COLOR) .color(SECTION_COLOR)
.shaping(Shaping::Advanced), .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() .into()
} }
@@ -92,7 +181,7 @@ pub fn primary_button(theme: &Theme, status: button::Status) -> button::Style {
background: Some(Background::Color(PRIMARY_HOVER)), background: Some(Background::Color(PRIMARY_HOVER)),
text_color: Color::WHITE, text_color: Color::WHITE,
border: iced::Border { border: iced::Border {
radius: 4.0.into(), radius: 6.0.into(),
..iced::Border::default() ..iced::Border::default()
}, },
..button::Style::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)), background: Some(Background::Color(PRIMARY_BG)),
text_color: Color::WHITE, text_color: Color::WHITE,
border: iced::Border { border: iced::Border {
radius: 4.0.into(), radius: 6.0.into(),
..iced::Border::default() ..iced::Border::default()
}, },
..button::Style::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)), background: Some(Background::Color(DANGER_HOVER)),
text_color: Color::WHITE, text_color: Color::WHITE,
border: iced::Border { border: iced::Border {
radius: 4.0.into(), radius: 6.0.into(),
..iced::Border::default() ..iced::Border::default()
}, },
..button::Style::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)), background: Some(Background::Color(DANGER_BG)),
text_color: Color::WHITE, text_color: Color::WHITE,
border: iced::Border { border: iced::Border {
radius: 4.0.into(), radius: 6.0.into(),
..iced::Border::default() ..iced::Border::default()
}, },
..button::Style::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. /// Toolbar-style row with right-aligned actions.
pub fn toolbar<'a, Message: 'a>( pub fn toolbar<'a, Message: 'a>(
left: Vec<Element<'a, Message>>, left: Vec<Element<'a, Message>>,
@@ -151,11 +300,28 @@ pub fn toolbar<'a, Message: 'a>(
.align_y(Alignment::Center) .align_y(Alignment::Center)
.wrap(); .wrap();
container(
row![left_row, right_row] row![left_row, right_row]
.padding(8) .spacing(10)
.spacing(8)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.width(Length::Fill),
)
.padding(10)
.width(Length::Fill) .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() .into()
} }
@@ -184,3 +350,31 @@ fn format_timestamp(ms: i64) -> String {
let min = ((secs % 3600) / 60) as u32; let min = ((secs % 3600) / 60) as u32;
format!("{y}-{m:02}-{d:02} {h:02}:{min:02}") 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
);
}
}

View File

@@ -1,6 +1,7 @@
#![cfg_attr(target_os = "windows", windows_subsystem = "windows")] #![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
use bds_ui::BdsApp; use bds_ui::BdsApp;
use bds_ui::components::inputs;
fn main() -> iced::Result { fn main() -> iced::Result {
let icon = let icon =
@@ -9,7 +10,7 @@ fn main() -> iced::Result {
iced::application("bDS", BdsApp::update, BdsApp::view) iced::application("bDS", BdsApp::update, BdsApp::view)
.subscription(BdsApp::subscription) .subscription(BdsApp::subscription)
.theme(|_| iced::Theme::Dark) .theme(|_| inputs::app_theme())
.window(iced::window::Settings { .window(iced::window::Settings {
size: iced::Size::new(1200.0, 800.0), size: iced::Size::new(1200.0, 800.0),
icon: Some(icon), icon: Some(icon),

View File

@@ -54,7 +54,7 @@ fn active_button_style(_theme: &Theme, _status: button::Status) -> button::Style
border: Border { border: Border {
color: Color::TRANSPARENT, color: Color::TRANSPARENT,
width: 0.0, width: 0.0,
radius: 0.0.into(), radius: 6.0.into(),
}, },
..button::Style::default() ..button::Style::default()
} }
@@ -72,7 +72,7 @@ fn inactive_button_style(_theme: &Theme, status: button::Status) -> button::Styl
border: Border { border: Border {
color: Color::TRANSPARENT, color: Color::TRANSPARENT,
width: 0.0, width: 0.0,
radius: 0.0.into(), radius: 6.0.into(),
}, },
..button::Style::default() ..button::Style::default()
} }

View File

@@ -238,6 +238,7 @@ pub fn view<'a>(
.on_press_maybe( .on_press_maybe(
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi)), ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi)),
) )
.style(inputs::secondary_button)
.padding([6, 16]) .padding([6, 16])
.into() .into()
} else { } else {
@@ -247,12 +248,14 @@ pub fn view<'a>(
.on_press_maybe( .on_press_maybe(
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::DetectLanguage)), ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::DetectLanguage)),
) )
.style(inputs::secondary_button)
.padding([6, 16]) .padding([6, 16])
.into(), .into(),
button(text(t(locale, "editor.translate")).size(13)) button(text(t(locale, "editor.translate")).size(13))
.on_press_maybe( .on_press_maybe(
ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::TranslateMetadata)), ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::TranslateMetadata)),
) )
.style(inputs::secondary_button)
.padding([6, 16]) .padding([6, 16])
.into(), .into(),
button(text(t(locale, "common.save")).size(13)) button(text(t(locale, "common.save")).size(13))
@@ -287,7 +290,11 @@ pub fn view<'a>(
flag.language.clone(), flag.language.clone(),
))) )))
.padding([4, 8]) .padding([4, 8])
.style(|_: &iced::Theme, _| button::Style::default()) .style(if flag.is_active {
inputs::primary_button
} else {
inputs::secondary_button
})
.into() .into()
}) })
.collect(); .collect();
@@ -331,6 +338,7 @@ pub fn view<'a>(
.color(Color::from_rgb(0.55, 0.58, 0.65)), .color(Color::from_rgb(0.55, 0.58, 0.65)),
] ]
.padding(8); .padding(8);
let preview_card = inputs::card(column![preview, info].spacing(4)).padding(8);
// Metadata fields // Metadata fields
let title_input = inputs::labeled_input( let title_input = inputs::labeled_input(
@@ -385,6 +393,7 @@ pub fn view<'a>(
Space::with_width(Length::Fill), Space::with_width(Length::Fill),
button(text(t(locale, "editor.linkToPost")).size(12)) button(text(t(locale, "editor.linkToPost")).size(12))
.on_press(Message::MediaEditor(MediaEditorMsg::TogglePostPicker)) .on_press(Message::MediaEditor(MediaEditorMsg::TogglePostPicker))
.style(inputs::secondary_button)
.padding([4, 10]), .padding([4, 10]),
] ]
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
@@ -478,19 +487,29 @@ pub fn view<'a>(
] ]
.padding(8); .padding(8);
let body = scrollable( let metadata = inputs::card(
column![ column![
header,
flags_bar,
preview,
info,
inputs::section_header(&t(locale, "editor.metadata")), inputs::section_header(&t(locale, "editor.metadata")),
meta_row1, meta_row1,
meta_row2, meta_row2,
meta_row3, meta_row3,
linked_posts_header, ]
post_picker, .spacing(12)
linked_posts_list, .width(Length::Fill),
);
let linked_posts = inputs::card(
column![linked_posts_header, post_picker, linked_posts_list]
.spacing(10)
.width(Length::Fill),
);
let body = scrollable(
column![
header,
flags_bar,
preview_card,
metadata,
linked_posts,
footer, footer,
] ]
.spacing(12) .spacing(12)

View File

@@ -4,13 +4,14 @@ use iced::widget::text::Shaping;
use iced::widget::{ use iced::widget::{
Space, button, checkbox, column, container, image, row, scrollable, text, text_input, Space, button, checkbox, column, container, image, row, scrollable, text, text_input,
}; };
use iced::{Alignment, Background, Border, Color, Element, Length, Theme}; use iced::{Alignment, Background, Border, Color, Element, Length, Shadow, Theme, Vector};
use bds_core::i18n::UiLocale; use bds_core::i18n::UiLocale;
use bds_core::model::Media; use bds_core::model::Media;
use bds_core::util::paths::thumbnail_path; use bds_core::util::paths::thumbnail_path;
use crate::app::Message; use crate::app::Message;
use crate::components::inputs;
use crate::i18n::t; use crate::i18n::t;
use crate::views::post_editor::PostEditorMsg; use crate::views::post_editor::PostEditorMsg;
@@ -138,61 +139,27 @@ fn modal_box_style(_theme: &Theme) -> container::Style {
border: Border { border: Border {
color: Color::from_rgb(0.30, 0.30, 0.35), color: Color::from_rgb(0.30, 0.30, 0.35),
width: 1.0, width: 1.0,
radius: 8.0.into(), radius: 10.0.into(),
},
shadow: Shadow {
color: Color::from_rgba(0.0, 0.0, 0.0, 0.4),
offset: Vector::new(0.0, 8.0),
blur_radius: 28.0,
}, },
..container::Style::default() ..container::Style::default()
} }
} }
fn cancel_button_style(_theme: &Theme, status: button::Status) -> button::Style { fn cancel_button_style(theme: &Theme, status: button::Status) -> button::Style {
let bg = match status { inputs::secondary_button(theme, status)
button::Status::Hovered => Color::from_rgb(0.28, 0.28, 0.33),
_ => Color::from_rgb(0.22, 0.22, 0.27),
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::from_rgb(0.80, 0.80, 0.85),
border: Border {
color: Color::from_rgb(0.35, 0.35, 0.40),
width: 1.0,
radius: 4.0.into(),
},
..button::Style::default()
}
} }
fn danger_button_style(_theme: &Theme, status: button::Status) -> button::Style { fn danger_button_style(theme: &Theme, status: button::Status) -> button::Style {
let bg = match status { inputs::danger_button(theme, status)
button::Status::Hovered => Color::from_rgb(0.85, 0.20, 0.20),
_ => Color::from_rgb(0.75, 0.15, 0.15),
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::WHITE,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 4.0.into(),
},
..button::Style::default()
}
} }
fn confirm_button_style(_theme: &Theme, status: button::Status) -> button::Style { fn confirm_button_style(theme: &Theme, status: button::Status) -> button::Style {
let bg = match status { inputs::primary_button(theme, status)
button::Status::Hovered => Color::from_rgb(0.20, 0.55, 0.85),
_ => Color::from_rgb(0.15, 0.45, 0.75),
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::WHITE,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 4.0.into(),
},
..button::Style::default()
}
} }
fn resolve_thumbnail_file(data_dir: Option<&Path>, media: &Media) -> Option<String> { fn resolve_thumbnail_file(data_dir: Option<&Path>, media: &Media) -> Option<String> {
@@ -471,7 +438,8 @@ pub fn view(
.size(13) .size(13)
.on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertLinkSearch(s))) .on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertLinkSearch(s)))
.padding([6, 10]) .padding([6, 10])
.width(Length::Fill); .width(Length::Fill)
.style(inputs::field_style);
let internal_content: Element<'static, Message> = if results.is_empty() { let internal_content: Element<'static, Message> = if results.is_empty() {
column![ column![
@@ -540,7 +508,7 @@ pub fn view(
border: Border { border: Border {
color: Color::from_rgb(0.30, 0.30, 0.35), color: Color::from_rgb(0.30, 0.30, 0.35),
width: 1.0, width: 1.0,
radius: 4.0.into(), radius: 6.0.into(),
}, },
..button::Style::default() ..button::Style::default()
}), }),
@@ -557,7 +525,8 @@ pub fn view(
.size(13) .size(13)
.on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertLinkUrlChanged(s))) .on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertLinkUrlChanged(s)))
.padding([6, 10]) .padding([6, 10])
.width(Length::Fill), .width(Length::Fill)
.style(inputs::field_style),
text_input( text_input(
t(locale, "modal.postInsertLink.externalTextPlaceholder").as_str(), t(locale, "modal.postInsertLink.externalTextPlaceholder").as_str(),
&external_text, &external_text,
@@ -565,7 +534,8 @@ pub fn view(
.size(13) .size(13)
.on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertLinkTextChanged(s))) .on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertLinkTextChanged(s)))
.padding([6, 10]) .padding([6, 10])
.width(Length::Fill), .width(Length::Fill)
.style(inputs::field_style),
Space::with_height(12.0), Space::with_height(12.0),
row![ row![
Space::with_width(Length::Fill), Space::with_width(Length::Fill),
@@ -594,7 +564,7 @@ pub fn view(
border: Border { border: Border {
color: Color::from_rgb(0.30, 0.30, 0.35), color: Color::from_rgb(0.30, 0.30, 0.35),
width: 1.0, width: 1.0,
radius: 4.0.into(), radius: 6.0.into(),
}, },
..button::Style::default() ..button::Style::default()
}) })
@@ -659,7 +629,8 @@ pub fn view(
.size(13) .size(13)
.on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertMediaSearch(s))) .on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertMediaSearch(s)))
.padding([6, 10]) .padding([6, 10])
.width(Length::Fill); .width(Length::Fill)
.style(inputs::field_style);
let media_items: Vec<Element<'static, Message>> = media_list let media_items: Vec<Element<'static, Message>> = media_list
.iter() .iter()

View File

@@ -26,7 +26,7 @@ fn tab_active(_theme: &Theme, _status: button::Status) -> button::Style {
border: Border { border: Border {
color: Color::from_rgb(0.30, 0.55, 0.90), color: Color::from_rgb(0.30, 0.55, 0.90),
width: 0.0, width: 0.0,
radius: 3.0.into(), radius: 6.0.into(),
}, },
..button::Style::default() ..button::Style::default()
} }
@@ -44,7 +44,7 @@ fn tab_inactive(_theme: &Theme, status: button::Status) -> button::Style {
border: Border { border: Border {
color: Color::TRANSPARENT, color: Color::TRANSPARENT,
width: 0.0, width: 0.0,
radius: 3.0.into(), radius: 6.0.into(),
}, },
..button::Style::default() ..button::Style::default()
} }

View File

@@ -396,7 +396,7 @@ pub fn view<'a>(
) )
.on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::ToggleQuickActions))) .on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::ToggleQuickActions)))
.padding([6, 16]) .padding([6, 16])
.style(status_bar::dropdown_trigger) .style(inputs::secondary_button)
.into(); .into();
let mut header_action_items: Vec<Element<'a, Message>> = vec![ let mut header_action_items: Vec<Element<'a, Message>> = vec![
@@ -421,6 +421,7 @@ pub fn view<'a>(
) )
.on_press(Message::PostEditor(PostEditorMsg::Duplicate)) .on_press(Message::PostEditor(PostEditorMsg::Duplicate))
.padding([6, 16]) .padding([6, 16])
.style(inputs::secondary_button)
.into(), .into(),
); );
} }
@@ -446,6 +447,7 @@ pub fn view<'a>(
) )
.on_press(Message::PostEditor(PostEditorMsg::Discard)) .on_press(Message::PostEditor(PostEditorMsg::Discard))
.padding([6, 16]) .padding([6, 16])
.style(inputs::secondary_button)
.into(), .into(),
); );
} }
@@ -520,7 +522,7 @@ pub fn view<'a>(
Space::new(0, 0).into() Space::new(0, 0).into()
}; };
let header = column![header_row, header_menu].spacing(6); let header = inputs::card(column![header_row, header_menu].spacing(6)).padding(10);
// ── Collapsible Metadata Section ── // ── Collapsible Metadata Section ──
let meta_toggle_label = if state.metadata_expanded { let meta_toggle_label = if state.metadata_expanded {
@@ -535,15 +537,13 @@ pub fn view<'a>(
.shaping(Shaping::Advanced), .shaping(Shaping::Advanced),
) )
.on_press(Message::PostEditor(PostEditorMsg::ToggleMetadata)) .on_press(Message::PostEditor(PostEditorMsg::ToggleMetadata))
.padding([4, 0]) .padding([8, 10])
.style(|_, _| button::Style { .width(Length::Fill)
background: None, .style(inputs::disclosure_button);
..button::Style::default()
});
// ── Translation Flags Bar (inline with metadata toggle) ── // ── Translation Flags Bar (inline with metadata toggle) ──
let flags = state.translation_flags(); let flags = state.translation_flags();
let meta_toggle_row: Element<'a, Message> = if flags.is_empty() { let meta_toggle_content: Element<'a, Message> = if flags.is_empty() {
meta_toggle.into() meta_toggle.into()
} else { } else {
let mut flag_row = row![].spacing(2); let mut flag_row = row![].spacing(2);
@@ -560,11 +560,13 @@ pub fn view<'a>(
}); });
flag_row = flag_row.push(btn); flag_row = flag_row.push(btn);
} }
row![meta_toggle, Space::with_width(Length::Fill), flag_row] row![meta_toggle, flag_row]
.spacing(8) .spacing(8)
.align_y(iced::Alignment::Center) .align_y(iced::Alignment::Center)
.into() .into()
}; };
let meta_toggle_row: Element<'a, Message> =
inputs::card(meta_toggle_content).padding([2, 4]).into();
let metadata_section: Element<'a, Message> = if state.metadata_expanded && !on_translation { let metadata_section: Element<'a, Message> = if state.metadata_expanded && !on_translation {
let title_input = inputs::labeled_input( let title_input = inputs::labeled_input(
@@ -598,13 +600,6 @@ pub fn view<'a>(
Some(&state.language), Some(&state.language),
|lang| Message::PostEditor(PostEditorMsg::LanguageChanged(lang)), |lang| Message::PostEditor(PostEditorMsg::LanguageChanged(lang)),
); );
let detect_language = button(
text(t(locale, "editor.detectLanguage"))
.size(12)
.shaping(Shaping::Advanced),
)
.on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::DetectLanguage)))
.padding([6, 12]);
let template_input = inputs::labeled_input( let template_input = inputs::labeled_input(
&t(locale, "editor.templateSlug"), &t(locale, "editor.templateSlug"),
"", "",
@@ -616,9 +611,9 @@ pub fn view<'a>(
state.do_not_translate, state.do_not_translate,
|b| Message::PostEditor(PostEditorMsg::ToggleDoNotTranslate(b)), |b| Message::PostEditor(PostEditorMsg::ToggleDoNotTranslate(b)),
); );
let language_block = column![language_input, detect_language].spacing(6); let meta_row2 = row![author_input, language_input, template_input, dnt]
let meta_row2 = row![author_input, language_block, template_input, dnt]
.spacing(16) .spacing(16)
.align_y(iced::Alignment::End)
.width(Length::Fill); .width(Length::Fill);
// Tags chip input // Tags chip input
@@ -695,7 +690,7 @@ pub fn view<'a>(
) )
.on_press(Message::PostEditor(PostEditorMsg::LinkExistingMedia)) .on_press(Message::PostEditor(PostEditorMsg::LinkExistingMedia))
.padding([4, 10]) .padding([4, 10])
.style(|_: &Theme, _| button::Style::default()) .style(inputs::secondary_button)
.into(); .into();
let linked_media_header: Element<'a, Message> = row![ let linked_media_header: Element<'a, Message> = row![
@@ -783,6 +778,7 @@ pub fn view<'a>(
Column::with_children(items).spacing(6).into() Column::with_children(items).spacing(6).into()
}; };
inputs::card(
column![ column![
meta_row1, meta_row1,
meta_row2, meta_row2,
@@ -792,8 +788,9 @@ pub fn view<'a>(
backlinks_section, backlinks_section,
linked_media_section, linked_media_section,
] ]
.spacing(8) .spacing(12)
.width(Length::Fill) .width(Length::Fill),
)
.into() .into()
} else { } else {
Space::new(0, 0).into() Space::new(0, 0).into()
@@ -812,19 +809,19 @@ pub fn view<'a>(
.shaping(Shaping::Advanced), .shaping(Shaping::Advanced),
) )
.on_press(Message::PostEditor(PostEditorMsg::ToggleExcerpt)) .on_press(Message::PostEditor(PostEditorMsg::ToggleExcerpt))
.padding([4, 0]) .padding([8, 10])
.style(|_, _| button::Style { .width(Length::Fill)
background: None, .style(inputs::disclosure_button);
..button::Style::default() let excerpt_toggle: Element<'a, Message> = inputs::card(excerpt_toggle).padding([2, 4]).into();
});
let excerpt_section: Element<'a, Message> = if state.excerpt_expanded { let excerpt_section: Element<'a, Message> = if state.excerpt_expanded {
inputs::labeled_input( inputs::card(inputs::labeled_input(
&t(locale, "editor.excerpt"), &t(locale, "editor.excerpt"),
&t(locale, "editor.excerptPlaceholder"), &t(locale, "editor.excerptPlaceholder"),
&state.excerpt, &state.excerpt,
|s| Message::PostEditor(PostEditorMsg::ExcerptChanged(s)), |s| Message::PostEditor(PostEditorMsg::ExcerptChanged(s)),
) ))
.into()
} else { } else {
Space::new(0, 0).into() Space::new(0, 0).into()
}; };
@@ -866,6 +863,7 @@ pub fn view<'a>(
) )
.on_press(Message::PostEditor(PostEditorMsg::InsertLink)) .on_press(Message::PostEditor(PostEditorMsg::InsertLink))
.padding([6, 16]) .padding([6, 16])
.style(inputs::secondary_button)
.into() .into()
} else { } else {
Space::new(0, 0).into() Space::new(0, 0).into()
@@ -878,6 +876,7 @@ pub fn view<'a>(
) )
.on_press(Message::PostEditor(PostEditorMsg::InsertMedia)) .on_press(Message::PostEditor(PostEditorMsg::InsertMedia))
.padding([6, 16]) .padding([6, 16])
.style(inputs::secondary_button)
.into() .into()
} else { } else {
Space::new(0, 0).into() Space::new(0, 0).into()
@@ -889,6 +888,7 @@ pub fn view<'a>(
) )
.on_press(Message::PostEditor(PostEditorMsg::Gallery)) .on_press(Message::PostEditor(PostEditorMsg::Gallery))
.padding([6, 16]) .padding([6, 16])
.style(inputs::secondary_button)
.into(), .into(),
], ],
); );
@@ -946,14 +946,14 @@ pub fn view<'a>(
excerpt_toggle, excerpt_toggle,
excerpt_section, excerpt_section,
] ]
.spacing(4) .spacing(8)
.width(Length::Fill), .width(Length::Fill),
) )
.height(Length::Shrink); .height(Length::Shrink);
// ── Full layout: top pane (shrink), editor (fill), footer (shrink) ── // ── Full layout: top pane (shrink), editor (fill), footer (shrink) ──
column![top_pane, body_toolbar, editor_widget, footer,] column![top_pane, body_toolbar, editor_widget, footer,]
.spacing(4) .spacing(8)
.padding(16) .padding(16)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
@@ -998,7 +998,8 @@ fn chip_input_field<'a>(
.on_input(on_input) .on_input(on_input)
.on_submit(on_submit) .on_submit(on_submit)
.size(13) .size(13)
.padding([4, 6]), .padding([8, 10])
.style(inputs::field_style),
] ]
.spacing(4) .spacing(4)
.width(Length::Fill) .width(Length::Fill)

View File

@@ -18,7 +18,7 @@ fn dropdown_bg(_theme: &Theme) -> container::Style {
border: Border { border: Border {
color: Color::from_rgb(0.30, 0.30, 0.35), color: Color::from_rgb(0.30, 0.30, 0.35),
width: 1.0, width: 1.0,
radius: 4.0.into(), radius: 10.0.into(),
}, },
..container::Style::default() ..container::Style::default()
} }
@@ -35,7 +35,7 @@ fn project_item(_theme: &Theme, status: button::Status) -> button::Style {
border: Border { border: Border {
color: Color::TRANSPARENT, color: Color::TRANSPARENT,
width: 0.0, width: 0.0,
radius: 2.0.into(), radius: 6.0.into(),
}, },
..button::Style::default() ..button::Style::default()
} }
@@ -52,7 +52,7 @@ fn project_item_active(_theme: &Theme, status: button::Status) -> button::Style
border: Border { border: Border {
color: Color::TRANSPARENT, color: Color::TRANSPARENT,
width: 0.0, width: 0.0,
radius: 2.0.into(), radius: 6.0.into(),
}, },
..button::Style::default() ..button::Style::default()
} }
@@ -69,7 +69,7 @@ fn new_project_btn(_theme: &Theme, status: button::Status) -> button::Style {
border: Border { border: Border {
color: Color::TRANSPARENT, color: Color::TRANSPARENT,
width: 0.0, width: 0.0,
radius: 2.0.into(), radius: 6.0.into(),
}, },
..button::Style::default() ..button::Style::default()
} }
@@ -214,7 +214,7 @@ pub fn trigger_style(_theme: &Theme, status: button::Status) -> button::Style {
border: Border { border: Border {
color: Color::TRANSPARENT, color: Color::TRANSPARENT,
width: 0.0, width: 0.0,
radius: 3.0.into(), radius: 6.0.into(),
}, },
..button::Style::default() ..button::Style::default()
} }

View File

@@ -132,10 +132,12 @@ pub fn view<'a>(state: &'a ScriptEditorState, locale: UiLocale) -> Element<'a, M
.into(), .into(),
button(text(t(locale, "editor.run")).size(13)) button(text(t(locale, "editor.run")).size(13))
.on_press(Message::ScriptEditor(ScriptEditorMsg::Run)) .on_press(Message::ScriptEditor(ScriptEditorMsg::Run))
.style(inputs::secondary_button)
.padding([6, 16]) .padding([6, 16])
.into(), .into(),
button(text(t(locale, "editor.checkSyntax")).size(13)) button(text(t(locale, "editor.checkSyntax")).size(13))
.on_press(Message::ScriptEditor(ScriptEditorMsg::CheckSyntax)) .on_press(Message::ScriptEditor(ScriptEditorMsg::CheckSyntax))
.style(inputs::secondary_button)
.padding([6, 16]) .padding([6, 16])
.into(), .into(),
button(text(t(locale, "modal.confirmDelete.delete")).size(13)) button(text(t(locale, "modal.confirmDelete.delete")).size(13))
@@ -200,6 +202,7 @@ pub fn view<'a>(state: &'a ScriptEditorState, locale: UiLocale) -> Element<'a, M
}); });
let meta_row2 = row![kind_select, entrypoint_input, enabled_check] let meta_row2 = row![kind_select, entrypoint_input, enabled_check]
.spacing(16) .spacing(16)
.align_y(iced::Alignment::End)
.width(Length::Fill); .width(Length::Fill);
// Content editor (CodeEditor with syntax highlighting based on file extension) // Content editor (CodeEditor with syntax highlighting based on file extension)
@@ -208,21 +211,28 @@ pub fn view<'a>(state: &'a ScriptEditorState, locale: UiLocale) -> Element<'a, M
} else { } else {
"lua" "lua"
}; };
let content_section: Element<'a, Message> = column![ let content_section: Element<'a, Message> = inputs::card(
column![
inputs::section_header(&t(locale, "editor.content")), inputs::section_header(&t(locale, "editor.content")),
Element::from( Element::from(
CodeEditor::new(&state.editor_buffer, highlighter(), syntax_ext,).on_change(|msg| { CodeEditor::new(&state.editor_buffer, highlighter(), syntax_ext,).on_change(
|msg| {
match msg { match msg {
EditorMessage::ContentChanged(s) => { EditorMessage::ContentChanged(s) => {
Message::ScriptEditor(ScriptEditorMsg::ContentChanged(s)) Message::ScriptEditor(ScriptEditorMsg::ContentChanged(s))
} }
EditorMessage::SaveRequested => Message::ScriptEditor(ScriptEditorMsg::Save), EditorMessage::SaveRequested => {
Message::ScriptEditor(ScriptEditorMsg::Save)
} }
}) }
}
)
), ),
] ]
.spacing(8) .spacing(10)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill),
)
.height(Length::Fill) .height(Length::Fill)
.into(); .into();
@@ -248,18 +258,18 @@ pub fn view<'a>(state: &'a ScriptEditorState, locale: UiLocale) -> Element<'a, M
.padding(8); .padding(8);
// Top pane: header + metadata (scrollable for overflow) // Top pane: header + metadata (scrollable for overflow)
let top_pane = scrollable( let metadata = inputs::card(
column![header, meta_row1, meta_row2,] column![meta_row1, meta_row2]
.spacing(12) .spacing(12)
.padding(16)
.width(Length::Fill), .width(Length::Fill),
) );
let top_pane = scrollable(column![header, metadata].spacing(12).width(Length::Fill))
.height(Length::Shrink); .height(Length::Shrink);
// Full layout: top pane (shrink), content (fill), validation + footer (shrink) // Full layout: top pane (shrink), content (fill), validation + footer (shrink)
column![top_pane, content_section, validation, footer,] column![top_pane, content_section, validation, footer,]
.spacing(4) .spacing(8)
.padding([0, 16]) .padding(16)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.into() .into()

View File

@@ -1,6 +1,6 @@
use iced::widget::text::Shaping; use iced::widget::text::Shaping;
use iced::widget::{button, column, container, row, scrollable, text, text_editor, text_input}; use iced::widget::{button, column, container, row, scrollable, text, text_editor, text_input};
use iced::{Alignment, Color, Element, Length, Theme}; use iced::{Alignment, Color, Element, Length};
use bds_core::i18n::UiLocale; use bds_core::i18n::UiLocale;
@@ -347,7 +347,9 @@ pub enum SettingsMsg {
pub fn view<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { pub fn view<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
let search = text_input(&t(locale, "sidebar.filter.search"), &state.search_query) let search = text_input(&t(locale, "sidebar.filter.search"), &state.search_query)
.on_input(|s| Message::Settings(SettingsMsg::SearchChanged(s))) .on_input(|s| Message::Settings(SettingsMsg::SearchChanged(s)))
.size(14); .size(14)
.padding([8, 10])
.style(inputs::field_style);
let query_lower = state.search_query.to_lowercase(); let query_lower = state.search_query.to_lowercase();
@@ -369,6 +371,7 @@ pub fn view<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, M
.color(Color::from_rgb(0.7, 0.72, 0.78)), .color(Color::from_rgb(0.7, 0.72, 0.78)),
button(text(t(locale, "common.clear")).size(13)) button(text(t(locale, "common.clear")).size(13))
.on_press(Message::Settings(SettingsMsg::SearchChanged(String::new()))) .on_press(Message::Settings(SettingsMsg::SearchChanged(String::new())))
.style(inputs::secondary_button)
.padding([6, 12]), .padding([6, 12]),
] ]
.spacing(8) .spacing(8)
@@ -413,10 +416,10 @@ fn render_section<'a>(
))) )))
.padding([6, 8]) .padding([6, 8])
.width(Length::Fill) .width(Length::Fill)
.style(|_: &Theme, _| button::Style::default()); .style(inputs::disclosure_button);
if collapsed { if collapsed {
return column![header].into(); return inputs::card(header).padding([2, 4]).into();
} }
let content: Element<'a, Message> = match section { let content: Element<'a, Message> = match section {
@@ -430,7 +433,7 @@ fn render_section<'a>(
SettingsSection::MCP => section_mcp(locale), SettingsSection::MCP => section_mcp(locale),
}; };
column![header, content].spacing(4).into() inputs::card(column![header, content].spacing(8)).into()
} }
fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
@@ -448,7 +451,8 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
text_editor(&state.project_description) text_editor(&state.project_description)
.on_action(|a| Message::Settings(SettingsMsg::ProjectDescriptionAction(a))) .on_action(|a| Message::Settings(SettingsMsg::ProjectDescriptionAction(a)))
.height(Length::Fixed(80.0)) .height(Length::Fixed(80.0))
.size(14), .size(14)
.style(inputs::text_editor_style),
] ]
.spacing(4); .spacing(4);
let data_path = row![ let data_path = row![
@@ -457,9 +461,11 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
},), },),
button(text(t(locale, "settings.browse")).size(12)) button(text(t(locale, "settings.browse")).size(12))
.on_press(Message::Settings(SettingsMsg::BrowseDataPath)) .on_press(Message::Settings(SettingsMsg::BrowseDataPath))
.style(inputs::secondary_button)
.padding([6, 12]), .padding([6, 12]),
button(text(t(locale, "settings.reset")).size(12)) button(text(t(locale, "settings.reset")).size(12))
.on_press(Message::Settings(SettingsMsg::ResetDataPath)) .on_press(Message::Settings(SettingsMsg::ResetDataPath))
.style(inputs::secondary_button)
.padding([6, 12]), .padding([6, 12]),
] ]
.spacing(8) .spacing(8)
@@ -694,7 +700,7 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
.spacing(8); .spacing(8);
column.push( column.push(
container( inputs::card(
column![ column![
text(category.name.clone()).size(15).color(Color::WHITE), text(category.name.clone()).size(15).color(Color::WHITE),
title, title,
@@ -721,6 +727,7 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
.padding([6, 12]), .padding([6, 12]),
button(text(t(locale, "settings.resetCategories")).size(13)) button(text(t(locale, "settings.resetCategories")).size(13))
.on_press(Message::Settings(SettingsMsg::ResetCategoriesToDefaults)) .on_press(Message::Settings(SettingsMsg::ResetCategoriesToDefaults))
.style(inputs::secondary_button)
.padding([6, 12]), .padding([6, 12]),
] ]
.spacing(8) .spacing(8)
@@ -788,6 +795,7 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
); );
let online_refresh = button(text(t(locale, "settings.refreshModels")).size(13)) let online_refresh = button(text(t(locale, "settings.refreshModels")).size(13))
.on_press(Message::Settings(SettingsMsg::RefreshOnlineModels)) .on_press(Message::Settings(SettingsMsg::RefreshOnlineModels))
.style(inputs::secondary_button)
.padding([6, 16]); .padding([6, 16]);
let airplane_url = inputs::labeled_input( let airplane_url = inputs::labeled_input(
@@ -804,6 +812,7 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
); );
let airplane_refresh = button(text(t(locale, "settings.refreshModels")).size(13)) let airplane_refresh = button(text(t(locale, "settings.refreshModels")).size(13))
.on_press(Message::Settings(SettingsMsg::RefreshAirplaneModels)) .on_press(Message::Settings(SettingsMsg::RefreshAirplaneModels))
.style(inputs::secondary_button)
.padding([6, 16]); .padding([6, 16]);
let default_model = inputs::labeled_select( let default_model = inputs::labeled_select(
@@ -838,7 +847,8 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
text_editor(&state.system_prompt) text_editor(&state.system_prompt)
.on_action(|a| Message::Settings(SettingsMsg::SystemPromptAction(a))) .on_action(|a| Message::Settings(SettingsMsg::SystemPromptAction(a)))
.height(Length::Fixed(200.0)) .height(Length::Fixed(200.0))
.size(14), .size(14)
.style(inputs::text_editor_style),
] ]
.spacing(4); .spacing(4);
let btns = row![ let btns = row![
@@ -848,6 +858,7 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
.padding([6, 16]), .padding([6, 16]),
button(text(t(locale, "settings.resetToDefault")).size(13)) button(text(t(locale, "settings.resetToDefault")).size(13))
.on_press(Message::Settings(SettingsMsg::ResetSystemPrompt)) .on_press(Message::Settings(SettingsMsg::ResetSystemPrompt))
.style(inputs::secondary_button)
.padding([6, 16]), .padding([6, 16]),
] ]
.spacing(8); .spacing(8);
@@ -930,30 +941,37 @@ fn section_data<'a>(locale: UiLocale) -> Element<'a, Message> {
let rebuild_btns = column![ let rebuild_btns = column![
button(text(t(locale, "settings.rebuildPosts")).size(13)) button(text(t(locale, "settings.rebuildPosts")).size(13))
.on_press(Message::Settings(SettingsMsg::RebuildPosts)) .on_press(Message::Settings(SettingsMsg::RebuildPosts))
.style(inputs::secondary_button)
.padding([6, 16]) .padding([6, 16])
.width(Length::Fill), .width(Length::Fill),
button(text(t(locale, "settings.rebuildMedia")).size(13)) button(text(t(locale, "settings.rebuildMedia")).size(13))
.on_press(Message::Settings(SettingsMsg::RebuildMedia)) .on_press(Message::Settings(SettingsMsg::RebuildMedia))
.style(inputs::secondary_button)
.padding([6, 16]) .padding([6, 16])
.width(Length::Fill), .width(Length::Fill),
button(text(t(locale, "settings.rebuildScripts")).size(13)) button(text(t(locale, "settings.rebuildScripts")).size(13))
.on_press(Message::Settings(SettingsMsg::RebuildScripts)) .on_press(Message::Settings(SettingsMsg::RebuildScripts))
.style(inputs::secondary_button)
.padding([6, 16]) .padding([6, 16])
.width(Length::Fill), .width(Length::Fill),
button(text(t(locale, "settings.rebuildTemplates")).size(13)) button(text(t(locale, "settings.rebuildTemplates")).size(13))
.on_press(Message::Settings(SettingsMsg::RebuildTemplates)) .on_press(Message::Settings(SettingsMsg::RebuildTemplates))
.style(inputs::secondary_button)
.padding([6, 16]) .padding([6, 16])
.width(Length::Fill), .width(Length::Fill),
button(text(t(locale, "settings.rebuildLinks")).size(13)) button(text(t(locale, "settings.rebuildLinks")).size(13))
.on_press(Message::Settings(SettingsMsg::RebuildLinks)) .on_press(Message::Settings(SettingsMsg::RebuildLinks))
.style(inputs::secondary_button)
.padding([6, 16]) .padding([6, 16])
.width(Length::Fill), .width(Length::Fill),
button(text(t(locale, "settings.rebuildSearchIndex")).size(13)) button(text(t(locale, "settings.rebuildSearchIndex")).size(13))
.on_press(Message::Settings(SettingsMsg::RebuildSearchIndex)) .on_press(Message::Settings(SettingsMsg::RebuildSearchIndex))
.style(inputs::secondary_button)
.padding([6, 16]) .padding([6, 16])
.width(Length::Fill), .width(Length::Fill),
button(text(t(locale, "settings.regenerateThumbnails")).size(13)) button(text(t(locale, "settings.regenerateThumbnails")).size(13))
.on_press(Message::Settings(SettingsMsg::RegenerateThumbnails)) .on_press(Message::Settings(SettingsMsg::RegenerateThumbnails))
.style(inputs::secondary_button)
.padding([6, 16]) .padding([6, 16])
.width(Length::Fill), .width(Length::Fill),
] ]
@@ -961,6 +979,7 @@ fn section_data<'a>(locale: UiLocale) -> Element<'a, Message> {
let open = button(text(t(locale, "settings.openDataFolder")).size(13)) let open = button(text(t(locale, "settings.openDataFolder")).size(13))
.on_press(Message::Settings(SettingsMsg::OpenDataFolder)) .on_press(Message::Settings(SettingsMsg::OpenDataFolder))
.style(inputs::secondary_button)
.padding([6, 16]); .padding([6, 16]);
column![rebuild_btns, open] column![rebuild_btns, open]

View File

@@ -8,6 +8,7 @@ use bds_core::i18n::UiLocale;
use bds_core::model::{Media, Post, Script, Template}; use bds_core::model::{Media, Post, Script, Template};
use crate::app::Message; use crate::app::Message;
use crate::components::inputs;
use crate::i18n::t; use crate::i18n::t;
use crate::state::navigation::SidebarView; use crate::state::navigation::SidebarView;
use crate::state::sidebar_filter::{CalendarYear, MediaFilter, PostFilter}; use crate::state::sidebar_filter::{CalendarYear, MediaFilter, PostFilter};
@@ -30,7 +31,10 @@ fn item_style(_theme: &Theme, status: button::Status) -> button::Style {
button::Style { button::Style {
background: Some(Background::Color(bg)), background: Some(Background::Color(bg)),
text_color: Color::from_rgb(0.80, 0.80, 0.85), text_color: Color::from_rgb(0.80, 0.80, 0.85),
border: Border::default(), border: Border {
radius: 6.0.into(),
..Border::default()
},
..button::Style::default() ..button::Style::default()
} }
} }
@@ -44,7 +48,10 @@ fn item_active_style(_theme: &Theme, status: button::Status) -> button::Style {
button::Style { button::Style {
background: Some(Background::Color(bg)), background: Some(Background::Color(bg)),
text_color: Color::WHITE, text_color: Color::WHITE,
border: Border::default(), border: Border {
radius: 6.0.into(),
..Border::default()
},
..button::Style::default() ..button::Style::default()
} }
} }
@@ -54,7 +61,7 @@ fn thumbnail_container_style(_theme: &Theme) -> container::Style {
container::Style { container::Style {
background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.17))), background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.17))),
border: Border { border: Border {
radius: 4.0.into(), radius: 6.0.into(),
..Border::default() ..Border::default()
}, },
..container::Style::default() ..container::Style::default()
@@ -145,19 +152,8 @@ fn format_post_date(unix_ms: i64) -> String {
} }
/// Search input style. /// Search input style.
fn search_input_style(_theme: &Theme, _status: text_input::Status) -> text_input::Style { fn search_input_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
text_input::Style { inputs::field_style(theme, status)
background: Background::Color(Color::from_rgb(0.12, 0.12, 0.15)),
border: Border {
color: Color::from_rgb(0.30, 0.30, 0.35),
width: 1.0,
radius: 3.0.into(),
},
icon: Color::from_rgb(0.50, 0.50, 0.55),
placeholder: Color::from_rgb(0.45, 0.45, 0.50),
value: Color::from_rgb(0.85, 0.85, 0.90),
selection: Color::from_rgba(0.35, 0.55, 0.85, 0.4),
}
} }
/// Style for filter toggle / clear buttons in the header. /// Style for filter toggle / clear buttons in the header.
@@ -169,7 +165,10 @@ fn filter_button_style(_theme: &Theme, status: button::Status) -> button::Style
button::Style { button::Style {
background: Some(Background::Color(bg)), background: Some(Background::Color(bg)),
text_color: Color::from_rgb(0.60, 0.60, 0.65), text_color: Color::from_rgb(0.60, 0.60, 0.65),
border: Border::default(), border: Border {
radius: 6.0.into(),
..Border::default()
},
..button::Style::default() ..button::Style::default()
} }
} }
@@ -183,7 +182,10 @@ fn filter_button_active_style(_theme: &Theme, status: button::Status) -> button:
button::Style { button::Style {
background: Some(Background::Color(bg)), background: Some(Background::Color(bg)),
text_color: Color::from_rgb(0.55, 0.70, 0.95), text_color: Color::from_rgb(0.55, 0.70, 0.95),
border: Border::default(), border: Border {
radius: 6.0.into(),
..Border::default()
},
..button::Style::default() ..button::Style::default()
} }
} }
@@ -773,7 +775,7 @@ pub fn view(
is_transient: true, is_transient: true,
is_dirty: false, is_dirty: false,
})) }))
.padding([3, 6]) .padding([5, 8])
.width(Length::Fill) .width(Length::Fill)
.style(style_fn) .style(style_fn)
.into() .into()
@@ -1025,7 +1027,7 @@ pub fn view(
is_transient: true, is_transient: true,
is_dirty: false, is_dirty: false,
})) }))
.padding([3, 6]) .padding([5, 8])
.width(Length::Fill) .width(Length::Fill)
.style(style_fn) .style(style_fn)
.into() .into()
@@ -1065,7 +1067,7 @@ pub fn view(
is_transient: true, is_transient: true,
is_dirty: false, is_dirty: false,
})) }))
.padding([3, 6]) .padding([5, 8])
.width(Length::Fill) .width(Length::Fill)
.style(style_fn) .style(style_fn)
.into() .into()
@@ -1106,7 +1108,7 @@ pub fn view(
}; };
button(container(label_text).width(Length::Fill)) button(container(label_text).width(Length::Fill))
.on_press(msg) .on_press(msg)
.padding([3, 6]) .padding([5, 8])
.width(Length::Fill) .width(Length::Fill)
.style(item_style) .style(item_style)
.into() .into()
@@ -1130,7 +1132,7 @@ pub fn view(
is_transient: false, is_transient: false,
is_dirty: false, is_dirty: false,
})) }))
.padding([3, 6]) .padding([5, 8])
.width(Length::Fill) .width(Length::Fill)
.style(item_style) .style(item_style)
.into() .into()

View File

@@ -5,6 +5,7 @@ use iced::{Background, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale; use bds_core::i18n::UiLocale;
use crate::app::Message; use crate::app::Message;
use crate::components::inputs;
use crate::i18n::t; use crate::i18n::t;
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@@ -25,6 +26,8 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a,
.size(13) .size(13)
.shaping(Shaping::Advanced), .shaping(Shaping::Advanced),
) )
.style(inputs::primary_button)
.padding([6, 16])
} else { } else {
button( button(
text(t(locale, "siteValidation.run")) text(t(locale, "siteValidation.run"))
@@ -32,6 +35,8 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a,
.shaping(Shaping::Advanced), .shaping(Shaping::Advanced),
) )
.on_press(Message::RunSiteValidation) .on_press(Message::RunSiteValidation)
.style(inputs::primary_button)
.padding([6, 16])
}; };
let has_issues = !state.missing_files.is_empty() let has_issues = !state.missing_files.is_empty()
|| !state.extra_files.is_empty() || !state.extra_files.is_empty()
@@ -42,6 +47,8 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a,
.size(13) .size(13)
.shaping(Shaping::Advanced), .shaping(Shaping::Advanced),
) )
.style(inputs::secondary_button)
.padding([6, 16])
} else if !state.is_running && state.error_message.is_none() && has_issues { } else if !state.is_running && state.error_message.is_none() && has_issues {
button( button(
text(t(locale, "siteValidation.apply")) text(t(locale, "siteValidation.apply"))
@@ -49,12 +56,16 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a,
.shaping(Shaping::Advanced), .shaping(Shaping::Advanced),
) )
.on_press(Message::ApplySiteValidation) .on_press(Message::ApplySiteValidation)
.style(inputs::primary_button)
.padding([6, 16])
} else { } else {
button( button(
text(t(locale, "siteValidation.apply")) text(t(locale, "siteValidation.apply"))
.size(13) .size(13)
.shaping(Shaping::Advanced), .shaping(Shaping::Advanced),
) )
.style(inputs::secondary_button)
.padding([6, 16])
}; };
let mut content = column![ let mut content = column![
@@ -132,17 +143,12 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a,
} }
fn help_text<'a>(value: String) -> Element<'a, Message> { fn help_text<'a>(value: String) -> Element<'a, Message> {
container( inputs::card(
text(value) text(value)
.size(14) .size(14)
.shaping(Shaping::Advanced) .shaping(Shaping::Advanced)
.color(Color::from_rgb(0.66, 0.66, 0.72)), .color(Color::from_rgb(0.66, 0.66, 0.72)),
) )
.padding(16)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.18))),
..container::Style::default()
})
.into() .into()
} }
@@ -156,7 +162,7 @@ fn section<'a>(title: String, entries: &'a [String], accent: Color) -> Element<'
) )
}); });
container( inputs::card(
column![ column![
text(title.to_string()) text(title.to_string())
.size(16) .size(16)
@@ -166,10 +172,5 @@ fn section<'a>(title: String, entries: &'a [String], accent: Color) -> Element<'
] ]
.spacing(10), .spacing(10),
) )
.padding(16)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.18))),
..container::Style::default()
})
.into() .into()
} }

View File

@@ -33,7 +33,7 @@ fn airplane_inactive(_theme: &Theme, status: button::Status) -> button::Style {
border: Border { border: Border {
color: Color::TRANSPARENT, color: Color::TRANSPARENT,
width: 0.0, width: 0.0,
radius: 3.0.into(), radius: 6.0.into(),
}, },
..button::Style::default() ..button::Style::default()
} }
@@ -51,7 +51,7 @@ fn airplane_active(_theme: &Theme, status: button::Status) -> button::Style {
border: Border { border: Border {
color: Color::TRANSPARENT, color: Color::TRANSPARENT,
width: 0.0, width: 0.0,
radius: 3.0.into(), radius: 6.0.into(),
}, },
..button::Style::default() ..button::Style::default()
} }
@@ -69,7 +69,7 @@ pub fn dropdown_trigger(_theme: &Theme, status: button::Status) -> button::Style
border: Border { border: Border {
color: Color::TRANSPARENT, color: Color::TRANSPARENT,
width: 0.0, width: 0.0,
radius: 3.0.into(), radius: 6.0.into(),
}, },
..button::Style::default() ..button::Style::default()
} }
@@ -87,7 +87,7 @@ pub fn dropdown_item(_theme: &Theme, status: button::Status) -> button::Style {
border: Border { border: Border {
color: Color::TRANSPARENT, color: Color::TRANSPARENT,
width: 0.0, width: 0.0,
radius: 2.0.into(), radius: 6.0.into(),
}, },
..button::Style::default() ..button::Style::default()
} }
@@ -100,7 +100,7 @@ pub fn dropdown_bg(_theme: &Theme) -> container::Style {
border: Border { border: Border {
color: Color::from_rgb(0.30, 0.30, 0.35), color: Color::from_rgb(0.30, 0.30, 0.35),
width: 1.0, width: 1.0,
radius: 4.0.into(), radius: 6.0.into(),
}, },
..container::Style::default() ..container::Style::default()
} }

View File

@@ -41,7 +41,7 @@ fn tab_active(_theme: &Theme, _status: button::Status) -> button::Style {
border: Border { border: Border {
color: Color::from_rgb(0.30, 0.55, 0.90), color: Color::from_rgb(0.30, 0.55, 0.90),
width: 0.0, width: 0.0,
radius: 4.0.into(), radius: 6.0.into(),
}, },
..button::Style::default() ..button::Style::default()
} }
@@ -59,7 +59,7 @@ fn tab_inactive(_theme: &Theme, status: button::Status) -> button::Style {
border: Border { border: Border {
color: Color::TRANSPARENT, color: Color::TRANSPARENT,
width: 0.0, width: 0.0,
radius: 4.0.into(), radius: 6.0.into(),
}, },
..button::Style::default() ..button::Style::default()
} }
@@ -86,7 +86,7 @@ fn tooltip_style(_theme: &Theme) -> container::Style {
border: Border { border: Border {
color: Color::from_rgb(0.35, 0.35, 0.40), color: Color::from_rgb(0.35, 0.35, 0.40),
width: 1.0, width: 1.0,
radius: 4.0.into(), radius: 6.0.into(),
}, },
..container::Style::default() ..container::Style::default()
} }

View File

@@ -117,7 +117,8 @@ pub enum TagsMsg {
/// Render the tags management view. /// Render the tags management view.
pub fn view<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> { pub fn view<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> {
let section_nav = row![ let section_nav = inputs::card(
row![
section_tab( section_tab(
&t(locale, "tags.nav.cloud"), &t(locale, "tags.nav.cloud"),
state.section == TagsSection::Cloud, state.section == TagsSection::Cloud,
@@ -139,8 +140,9 @@ pub fn view<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Messa
TagsSection::Discover TagsSection::Discover
), ),
] ]
.spacing(4) .spacing(6),
.padding(8); )
.padding(6);
let content: Element<'a, Message> = match state.section { let content: Element<'a, Message> = match state.section {
TagsSection::Cloud => view_cloud(state, locale), TagsSection::Cloud => view_cloud(state, locale),
@@ -150,33 +152,20 @@ pub fn view<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Messa
}; };
column![section_nav, content] column![section_nav, content]
.spacing(4)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.into() .into()
} }
fn section_tab<'a>(label: &str, active: bool, section: TagsSection) -> Element<'a, Message> { fn section_tab<'a>(label: &str, active: bool, section: TagsSection) -> Element<'a, Message> {
let color = if active { button(text(label.to_string()).size(13))
Color::WHITE
} else {
Color::from_rgb(0.55, 0.58, 0.65)
};
button(text(label.to_string()).size(13).color(color))
.on_press(Message::Tags(TagsMsg::SetSection(section))) .on_press(Message::Tags(TagsMsg::SetSection(section)))
.padding([6, 12]) .padding([6, 12])
.style(move |_theme: &Theme, _status| { .style(if active {
if active { inputs::primary_button
button::Style {
background: Some(Background::Color(Color::from_rgb(0.25, 0.27, 0.33))),
border: iced::Border {
radius: 4.0.into(),
..iced::Border::default()
},
..button::Style::default()
}
} else { } else {
button::Style::default() inputs::secondary_button
}
}) })
.into() .into()
} }
@@ -212,8 +201,8 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
} else { } else {
1.0 1.0
}; };
const MIN_FONT: f32 = 11.0; const MIN_FONT: f32 = 12.0;
const MAX_FONT: f32 = 24.0; const MAX_FONT: f32 = 18.0;
let chips: Vec<Element<'a, Message>> = state let chips: Vec<Element<'a, Message>> = state
.tags .tags
@@ -243,16 +232,17 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
) )
.on_press(Message::Tags(TagsMsg::ToggleTagSelection(tag.id.clone()))) .on_press(Message::Tags(TagsMsg::ToggleTagSelection(tag.id.clone())))
.padding([vert_pad, 10]) .padding([vert_pad, 10])
.style(move |_: &Theme, _| button::Style { .style(move |_: &Theme, status| button::Style {
background: Some(Background::Color(color)), background: Some(Background::Color(match (selected, status) {
(true, button::Status::Hovered) => Color::from_rgb(0.20, 0.24, 0.30),
(true, _) => Color::from_rgb(0.17, 0.20, 0.25),
(false, button::Status::Hovered) => Color::from_rgb(0.23, 0.24, 0.26),
(false, _) => Color::from_rgb(0.18, 0.18, 0.19),
})),
border: iced::Border { border: iced::Border {
radius: 12.0.into(), radius: 12.0.into(),
width: if selected { 2.0 } else { 0.0 }, width: if selected { 2.0 } else { 1.0 },
color: if selected { color,
Color::WHITE
} else {
Color::TRANSPARENT
},
}, },
text_color: Color::WHITE, text_color: Color::WHITE,
..button::Style::default() ..button::Style::default()
@@ -277,6 +267,7 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
.color(Color::from_rgb(0.75, 0.77, 0.82)), .color(Color::from_rgb(0.75, 0.77, 0.82)),
button(text(t(locale, "tags.clearSelection")).size(12)) button(text(t(locale, "tags.clearSelection")).size(12))
.on_press(Message::Tags(TagsMsg::ClearSelection)) .on_press(Message::Tags(TagsMsg::ClearSelection))
.style(inputs::secondary_button)
.padding([4, 8]), .padding([4, 8]),
] ]
.spacing(8) .spacing(8)
@@ -285,13 +276,15 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
}; };
scrollable( scrollable(
column![inputs::card(
column![ column![
inputs::section_header(&t(locale, "tags.cloudSection")), inputs::section_header(&t(locale, "tags.cloudSection")),
selection_summary, selection_summary,
row(chips).spacing(6).wrap(), row(chips).spacing(6).wrap(),
] ]
.spacing(12) .spacing(12)
.width(Length::Fill) .width(Length::Fill),
)]
.padding(16), .padding(16),
) )
.width(Length::Fill) .width(Length::Fill)
@@ -326,7 +319,9 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
let search = text_input(&t(locale, "sidebar.filter.search"), &state.search_query) let search = text_input(&t(locale, "sidebar.filter.search"), &state.search_query)
.on_input(|value| Message::Tags(TagsMsg::SearchChanged(value))) .on_input(|value| Message::Tags(TagsMsg::SearchChanged(value)))
.size(14); .size(14)
.padding([8, 10])
.style(inputs::field_style);
let filtered: Vec<&Tag> = state let filtered: Vec<&Tag> = state
.tags .tags
@@ -446,13 +441,18 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
scrollable( scrollable(
column![ column![
create_panel, inputs::card(create_panel),
inputs::card(
column![
inputs::section_header(&t(locale, "tags.manageSection")), inputs::section_header(&t(locale, "tags.manageSection")),
search, search,
tag_list, tag_list,
edit_panel, edit_panel,
] ]
.spacing(12) .spacing(12)
),
]
.spacing(12)
.padding(16) .padding(16)
.width(Length::Fill), .width(Length::Fill),
) )
@@ -491,6 +491,7 @@ fn view_merge<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
}) })
.unwrap_or_default(); .unwrap_or_default();
container(inputs::card(
column![ column![
inputs::section_header(&t(locale, "tags.mergeSection")), inputs::section_header(&t(locale, "tags.mergeSection")),
text(tw( text(tw(
@@ -509,7 +510,9 @@ fn view_merge<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
if merge_preview.is_empty() { if merge_preview.is_empty() {
Element::from(Space::new(0, 0)) Element::from(Space::new(0, 0))
} else { } else {
container(text(tw(locale, "tags.mergePreview", &[("tags", &merge_preview)])).size(12)) container(
text(tw(locale, "tags.mergePreview", &[("tags", &merge_preview)])).size(12),
)
.padding([4, 0]) .padding([4, 0])
.into() .into()
}, },
@@ -523,13 +526,15 @@ fn view_merge<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
.style(inputs::primary_button) .style(inputs::primary_button)
.padding([6, 16]), .padding([6, 16]),
] ]
.spacing(12) .spacing(12),
))
.padding(16) .padding(16)
.width(Length::Fill) .width(Length::Fill)
.into() .into()
} }
fn view_discover<'a>(_state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> { fn view_discover<'a>(_state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> {
container(inputs::card(
column![ column![
inputs::section_header(&t(locale, "tags.discoverSection")), inputs::section_header(&t(locale, "tags.discoverSection")),
text(t(locale, "tags.discoverDescription")) text(t(locale, "tags.discoverDescription"))
@@ -540,7 +545,8 @@ fn view_discover<'a>(_state: &'a TagsViewState, locale: UiLocale) -> Element<'a,
.style(inputs::primary_button) .style(inputs::primary_button)
.padding([6, 16]), .padding([6, 16]),
] ]
.spacing(12) .spacing(12),
))
.padding(16) .padding(16)
.width(Length::Fill) .width(Length::Fill)
.into() .into()

View File

@@ -121,6 +121,7 @@ pub fn view<'a>(state: &'a TemplateEditorState, locale: UiLocale) -> Element<'a,
.into(), .into(),
button(text(t(locale, "editor.validate")).size(13)) button(text(t(locale, "editor.validate")).size(13))
.on_press(Message::TemplateEditor(TemplateEditorMsg::Validate)) .on_press(Message::TemplateEditor(TemplateEditorMsg::Validate))
.style(inputs::secondary_button)
.padding([6, 16]) .padding([6, 16])
.into(), .into(),
button(text(t(locale, "modal.confirmDelete.delete")).size(13)) button(text(t(locale, "modal.confirmDelete.delete")).size(13))
@@ -168,10 +169,12 @@ pub fn view<'a>(state: &'a TemplateEditorState, locale: UiLocale) -> Element<'a,
}); });
let meta_row2 = row![kind_select, enabled_check] let meta_row2 = row![kind_select, enabled_check]
.spacing(16) .spacing(16)
.align_y(iced::Alignment::End)
.width(Length::Fill); .width(Length::Fill);
// Content editor (CodeEditor with Liquid/HTML syntax highlighting) // Content editor (CodeEditor with Liquid/HTML syntax highlighting)
let content_section: Element<'a, Message> = column![ let content_section: Element<'a, Message> = inputs::card(
column![
inputs::section_header(&t(locale, "editor.content")), inputs::section_header(&t(locale, "editor.content")),
Element::from( Element::from(
CodeEditor::new(&state.editor_buffer, highlighter(), "liquid",).on_change(|msg| { CodeEditor::new(&state.editor_buffer, highlighter(), "liquid",).on_change(|msg| {
@@ -186,8 +189,10 @@ pub fn view<'a>(state: &'a TemplateEditorState, locale: UiLocale) -> Element<'a,
}) })
), ),
] ]
.spacing(8) .spacing(10)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill),
)
.height(Length::Fill) .height(Length::Fill)
.into(); .into();
@@ -213,18 +218,18 @@ pub fn view<'a>(state: &'a TemplateEditorState, locale: UiLocale) -> Element<'a,
.padding(8); .padding(8);
// Top pane: header + metadata (scrollable for overflow) // Top pane: header + metadata (scrollable for overflow)
let top_pane = scrollable( let metadata = inputs::card(
column![header, meta_row1, meta_row2,] column![meta_row1, meta_row2]
.spacing(12) .spacing(12)
.padding(16)
.width(Length::Fill), .width(Length::Fill),
) );
let top_pane = scrollable(column![header, metadata].spacing(12).width(Length::Fill))
.height(Length::Shrink); .height(Length::Shrink);
// Full layout: top pane (shrink), content (fill), validation + footer (shrink) // Full layout: top pane (shrink), content (fill), validation + footer (shrink)
column![top_pane, content_section, validation, footer,] column![top_pane, content_section, validation, footer,]
.spacing(4) .spacing(8)
.padding([0, 16]) .padding(16)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.into() .into()

View File

@@ -31,7 +31,7 @@ fn toast_style(level: ToastLevel) -> impl Fn(&Theme) -> container::Style {
border: Border { border: Border {
color: toast_border(level), color: toast_border(level),
width: 1.0, width: 1.0,
radius: 4.0.into(), radius: 8.0.into(),
}, },
..container::Style::default() ..container::Style::default()
} }

49
docs/UI_STYLE_GUIDE.md Normal file
View File

@@ -0,0 +1,49 @@
# UI Style Guide
Use the post editor as the visual reference for every screen. New UI must feel like part of the same application in both the sidebar and editor area.
## Source of truth
- Reuse the primitives in `crates/bds-ui/src/components/inputs.rs`.
- Reuse the code-editor theme in `crates/bds-editor/src/widget.rs`.
- Follow `crates/bds-ui/src/views/post_editor.rs` for editor hierarchy and spacing.
- Extend a shared primitive when several screens need the same treatment; do not copy color or border closures into each view.
## Layout
- Use 16 px outer editor padding and 812 px spacing between groups.
- Put the editor header, metadata, summaries, previews, and content editors on `inputs::card` surfaces.
- Use `inputs::toolbar` for content modes and related actions.
- Use `inputs::disclosure_button` inside a compact card for collapsible sections. Expanded content belongs in a separate card directly below it.
- Keep sidebars visually quieter than editor content. Sidebar rows use 6 px corner radii, 5×8 px padding, and a distinct selected state.
- Avoid separators and boxes when spacing or a shared surface already communicates the grouping.
## Controls
- Text fields use `inputs::labeled_input` or `inputs::field_style`.
- Selects use `inputs::labeled_select`; multiline fields use `inputs::text_editor_style`.
- Primary actions use `inputs::primary_button`, secondary actions use `inputs::secondary_button`, and destructive actions use `inputs::danger_button`.
- Controls have 6 px corner radii; cards use 10 px; chips and badges may use pill radii.
- Keep one visually dominant primary action per action group.
- Put infrequent operations in Quick Actions instead of adding buttons to metadata forms.
- Do not use Iced's default field or button styling in application screens.
## Editor consistency
Every entity editor should have the same order:
1. Header card with title/status and actions.
2. Metadata card or collapsible metadata section.
3. Content toolbar when the content has modes or insertion actions.
4. Content editor or preview card that receives the remaining height.
5. Validation and timestamps as quiet supporting information.
Labels, placeholders, actions, empty states, and status text must use the UI localization system.
## Before merging UI work
- Compare the changed screen with the post editor at the same window size.
- Check its sidebar as well as its editor area.
- Check collapsed and expanded disclosures, hover/focus states, empty states, and disabled actions.
- Check media, script, template, Settings, and Tags screens when changing a shared primitive.
- Run `cargo test --workspace`, `cargo build --workspace`, and visually inspect the macOS app bundle.