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.
## 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
- 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 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.

View File

@@ -1,9 +1,11 @@
//! Verify that the Rust Diesel models read the compatibility fixture produced by bDS.
mod support;
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use bds_core::db::Database;
use bds_core::db::queries::{
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 SPIDER_ID: &str = "eb0cf9d7-6fbd-4b74-9be3-759d6e16f240";
fn fixture_db() -> Database {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/compatibility-projects/rfc1437-sample/bds.db");
assert!(path.exists(), "fixture DB not found at {}", path.display());
Database::open(&path).unwrap()
fn fixture_db() -> support::FixtureDatabase {
support::fixture_database()
}
#[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]

View File

@@ -1,7 +1,8 @@
//! Executable checks for storage-related Allium claims.
mod support;
use std::collections::HashSet;
use std::path::PathBuf;
use bds_core::db::Database;
use bds_core::db::queries::{
@@ -13,12 +14,8 @@ use bds_core::model::{
};
use diesel::prelude::*;
fn fixture_db() -> Database {
Database::open(
&PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/compatibility-projects/rfc1437-sample/bds.db"),
)
.unwrap()
fn fixture_db() -> support::FixtureDatabase {
support::fixture_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::keyboard;
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::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 BG_COLOR: Color = Color::from_rgb(0.18, 0.20, 0.25);
const GUTTER_BG: Color = Color::from_rgb(0.15, 0.17, 0.21);
const TEXT_COLOR: Color = Color::from_rgb(0.85, 0.85, 0.85);
const GUTTER_TEXT: Color = Color::from_rgb(0.45, 0.48, 0.55);
const CURSOR_COLOR: Color = Color::from_rgb(0.9, 0.9, 0.2);
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 TEXT_COLOR: Color = rgb8(0xD4, 0xD4, 0xD4);
const GUTTER_TEXT: Color = rgb8(0x85, 0x85, 0x85);
const CURSOR_COLOR: Color = rgb8(0xAE, 0xAF, 0xAD);
const ACTIVE_LINE_NUM: Color = rgb8(0xC6, 0xC6, 0xC6);
const SELECTION_BG: Color = rgba8(0x26, 0x4F, 0x78, 0.85);
const SCROLLBAR_WIDTH: f32 = 10.0;
const SCROLLBAR_TRACK: Color = Color::from_rgba(0.25, 0.27, 0.32, 0.5);
const SCROLLBAR_THUMB: Color = Color::from_rgba(0.50, 0.53, 0.60, 0.7);
const SCROLLBAR_THUMB_HOVER: Color = Color::from_rgba(0.60, 0.63, 0.70, 0.9);
const SCROLLBAR_TRACK: Color = Color::TRANSPARENT;
const SCROLLBAR_THUMB: Color = rgba8(0x79, 0x79, 0x79, 0.45);
const SCROLLBAR_THUMB_HOVER: Color = rgba8(0x79, 0x79, 0x79, 0.75);
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> {
if is_command_shortcut {
return None;
@@ -303,7 +315,7 @@ where
&self,
tree: &widget::Tree,
renderer: &mut Renderer,
_theme: &Theme,
theme: &Theme,
_style: &renderer::Style,
layout: Layout<'_>,
_cursor: mouse::Cursor,
@@ -317,24 +329,33 @@ where
renderer.fill_quad(
renderer::Quad {
bounds,
border: iced::Border::default(),
shadow: iced::Shadow::default(),
border: iced::Border {
color: Color::from_rgb8(0x35, 0x35, 0x35),
width: 1.0,
radius: 8.0.into(),
},
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,
},
},
BG_COLOR,
theme.palette().background,
);
// Gutter background
let gutter_bounds = Rectangle {
width: GUTTER_WIDTH,
..bounds
};
// Subtle gutter divider; the shared background keeps the editor visually light.
renderer.fill_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(),
shadow: iced::Shadow::default(),
},
GUTTER_BG,
Color::from_rgb8(0x2B, 0x2B, 0x2B),
);
let metrics = mono_metrics();
@@ -349,7 +370,7 @@ where
let highlighted = self.highlighter.highlight_lines(&full_text, syntax);
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 {
(text_area_width / metrics.char_width).floor() as usize
} else {
@@ -382,7 +403,7 @@ where
line_idx += 1;
}
let text_x = bounds.x + GUTTER_WIDTH + 8.0;
let text_x = bounds.x + GUTTER_WIDTH + TEXT_PADDING;
// Render visual rows
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 bounds = layout.bounds();
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 {
(text_area_width / metrics.char_width).floor() as usize
} else {
@@ -752,8 +773,8 @@ where
if let Some(pos) = cursor.position_in(bounds) {
let mut buf = self.buffer.borrow_mut();
let vis_row = (pos.y / metrics.line_height) as usize;
let raw_col =
((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize;
let raw_col = ((pos.x - GUTTER_WIDTH - TEXT_PADDING).max(0.0)
/ metrics.char_width) as usize;
let (line, char_off) =
visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl)
.unwrap_or((buf.line_count().saturating_sub(1), 0));
@@ -792,8 +813,8 @@ where
if let Some(pos) = cursor.position_in(bounds) {
let mut buf = self.buffer.borrow_mut();
let vis_row = (pos.y / metrics.line_height) as usize;
let raw_col =
((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize;
let raw_col = ((pos.x - GUTTER_WIDTH - TEXT_PADDING).max(0.0)
/ metrics.char_width) as usize;
let (line, char_off) =
visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl)
.unwrap_or((buf.line_count().saturating_sub(1), 0));

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
);
}
}

View File

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

View File

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

View File

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

View File

@@ -4,13 +4,14 @@ use iced::widget::text::Shaping;
use iced::widget::{
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::model::Media;
use bds_core::util::paths::thumbnail_path;
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::t;
use crate::views::post_editor::PostEditorMsg;
@@ -138,61 +139,27 @@ fn modal_box_style(_theme: &Theme) -> container::Style {
border: Border {
color: Color::from_rgb(0.30, 0.30, 0.35),
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()
}
}
fn cancel_button_style(_theme: &Theme, status: button::Status) -> button::Style {
let bg = match 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 cancel_button_style(theme: &Theme, status: button::Status) -> button::Style {
inputs::secondary_button(theme, status)
}
fn danger_button_style(_theme: &Theme, status: button::Status) -> button::Style {
let bg = match 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 danger_button_style(theme: &Theme, status: button::Status) -> button::Style {
inputs::danger_button(theme, status)
}
fn confirm_button_style(_theme: &Theme, status: button::Status) -> button::Style {
let bg = match 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 confirm_button_style(theme: &Theme, status: button::Status) -> button::Style {
inputs::primary_button(theme, status)
}
fn resolve_thumbnail_file(data_dir: Option<&Path>, media: &Media) -> Option<String> {
@@ -471,7 +438,8 @@ pub fn view(
.size(13)
.on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertLinkSearch(s)))
.padding([6, 10])
.width(Length::Fill);
.width(Length::Fill)
.style(inputs::field_style);
let internal_content: Element<'static, Message> = if results.is_empty() {
column![
@@ -540,7 +508,7 @@ pub fn view(
border: Border {
color: Color::from_rgb(0.30, 0.30, 0.35),
width: 1.0,
radius: 4.0.into(),
radius: 6.0.into(),
},
..button::Style::default()
}),
@@ -557,7 +525,8 @@ pub fn view(
.size(13)
.on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertLinkUrlChanged(s)))
.padding([6, 10])
.width(Length::Fill),
.width(Length::Fill)
.style(inputs::field_style),
text_input(
t(locale, "modal.postInsertLink.externalTextPlaceholder").as_str(),
&external_text,
@@ -565,7 +534,8 @@ pub fn view(
.size(13)
.on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertLinkTextChanged(s)))
.padding([6, 10])
.width(Length::Fill),
.width(Length::Fill)
.style(inputs::field_style),
Space::with_height(12.0),
row![
Space::with_width(Length::Fill),
@@ -594,7 +564,7 @@ pub fn view(
border: Border {
color: Color::from_rgb(0.30, 0.30, 0.35),
width: 1.0,
radius: 4.0.into(),
radius: 6.0.into(),
},
..button::Style::default()
})
@@ -659,7 +629,8 @@ pub fn view(
.size(13)
.on_input(|s| Message::PostEditor(PostEditorMsg::PostInsertMediaSearch(s)))
.padding([6, 10])
.width(Length::Fill);
.width(Length::Fill)
.style(inputs::field_style);
let media_items: Vec<Element<'static, Message>> = media_list
.iter()

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
use iced::widget::text::Shaping;
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;
@@ -347,7 +347,9 @@ pub enum SettingsMsg {
pub fn view<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
let search = text_input(&t(locale, "sidebar.filter.search"), &state.search_query)
.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();
@@ -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)),
button(text(t(locale, "common.clear")).size(13))
.on_press(Message::Settings(SettingsMsg::SearchChanged(String::new())))
.style(inputs::secondary_button)
.padding([6, 12]),
]
.spacing(8)
@@ -413,10 +416,10 @@ fn render_section<'a>(
)))
.padding([6, 8])
.width(Length::Fill)
.style(|_: &Theme, _| button::Style::default());
.style(inputs::disclosure_button);
if collapsed {
return column![header].into();
return inputs::card(header).padding([2, 4]).into();
}
let content: Element<'a, Message> = match section {
@@ -430,7 +433,7 @@ fn render_section<'a>(
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> {
@@ -448,7 +451,8 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
text_editor(&state.project_description)
.on_action(|a| Message::Settings(SettingsMsg::ProjectDescriptionAction(a)))
.height(Length::Fixed(80.0))
.size(14),
.size(14)
.style(inputs::text_editor_style),
]
.spacing(4);
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))
.on_press(Message::Settings(SettingsMsg::BrowseDataPath))
.style(inputs::secondary_button)
.padding([6, 12]),
button(text(t(locale, "settings.reset")).size(12))
.on_press(Message::Settings(SettingsMsg::ResetDataPath))
.style(inputs::secondary_button)
.padding([6, 12]),
]
.spacing(8)
@@ -694,7 +700,7 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
.spacing(8);
column.push(
container(
inputs::card(
column![
text(category.name.clone()).size(15).color(Color::WHITE),
title,
@@ -721,6 +727,7 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
.padding([6, 12]),
button(text(t(locale, "settings.resetCategories")).size(13))
.on_press(Message::Settings(SettingsMsg::ResetCategoriesToDefaults))
.style(inputs::secondary_button)
.padding([6, 12]),
]
.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))
.on_press(Message::Settings(SettingsMsg::RefreshOnlineModels))
.style(inputs::secondary_button)
.padding([6, 16]);
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))
.on_press(Message::Settings(SettingsMsg::RefreshAirplaneModels))
.style(inputs::secondary_button)
.padding([6, 16]);
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)
.on_action(|a| Message::Settings(SettingsMsg::SystemPromptAction(a)))
.height(Length::Fixed(200.0))
.size(14),
.size(14)
.style(inputs::text_editor_style),
]
.spacing(4);
let btns = row![
@@ -848,6 +858,7 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
.padding([6, 16]),
button(text(t(locale, "settings.resetToDefault")).size(13))
.on_press(Message::Settings(SettingsMsg::ResetSystemPrompt))
.style(inputs::secondary_button)
.padding([6, 16]),
]
.spacing(8);
@@ -930,30 +941,37 @@ fn section_data<'a>(locale: UiLocale) -> Element<'a, Message> {
let rebuild_btns = column![
button(text(t(locale, "settings.rebuildPosts")).size(13))
.on_press(Message::Settings(SettingsMsg::RebuildPosts))
.style(inputs::secondary_button)
.padding([6, 16])
.width(Length::Fill),
button(text(t(locale, "settings.rebuildMedia")).size(13))
.on_press(Message::Settings(SettingsMsg::RebuildMedia))
.style(inputs::secondary_button)
.padding([6, 16])
.width(Length::Fill),
button(text(t(locale, "settings.rebuildScripts")).size(13))
.on_press(Message::Settings(SettingsMsg::RebuildScripts))
.style(inputs::secondary_button)
.padding([6, 16])
.width(Length::Fill),
button(text(t(locale, "settings.rebuildTemplates")).size(13))
.on_press(Message::Settings(SettingsMsg::RebuildTemplates))
.style(inputs::secondary_button)
.padding([6, 16])
.width(Length::Fill),
button(text(t(locale, "settings.rebuildLinks")).size(13))
.on_press(Message::Settings(SettingsMsg::RebuildLinks))
.style(inputs::secondary_button)
.padding([6, 16])
.width(Length::Fill),
button(text(t(locale, "settings.rebuildSearchIndex")).size(13))
.on_press(Message::Settings(SettingsMsg::RebuildSearchIndex))
.style(inputs::secondary_button)
.padding([6, 16])
.width(Length::Fill),
button(text(t(locale, "settings.regenerateThumbnails")).size(13))
.on_press(Message::Settings(SettingsMsg::RegenerateThumbnails))
.style(inputs::secondary_button)
.padding([6, 16])
.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))
.on_press(Message::Settings(SettingsMsg::OpenDataFolder))
.style(inputs::secondary_button)
.padding([6, 16]);
column![rebuild_btns, open]

View File

@@ -8,6 +8,7 @@ use bds_core::i18n::UiLocale;
use bds_core::model::{Media, Post, Script, Template};
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::t;
use crate::state::navigation::SidebarView;
use crate::state::sidebar_filter::{CalendarYear, MediaFilter, PostFilter};
@@ -30,7 +31,10 @@ fn item_style(_theme: &Theme, status: button::Status) -> button::Style {
button::Style {
background: Some(Background::Color(bg)),
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()
}
}
@@ -44,7 +48,10 @@ fn item_active_style(_theme: &Theme, status: button::Status) -> button::Style {
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::WHITE,
border: Border::default(),
border: Border {
radius: 6.0.into(),
..Border::default()
},
..button::Style::default()
}
}
@@ -54,7 +61,7 @@ fn thumbnail_container_style(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.17))),
border: Border {
radius: 4.0.into(),
radius: 6.0.into(),
..Border::default()
},
..container::Style::default()
@@ -145,19 +152,8 @@ fn format_post_date(unix_ms: i64) -> String {
}
/// Search input style.
fn search_input_style(_theme: &Theme, _status: text_input::Status) -> text_input::Style {
text_input::Style {
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),
}
fn search_input_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
inputs::field_style(theme, status)
}
/// 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 {
background: Some(Background::Color(bg)),
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()
}
}
@@ -183,7 +182,10 @@ fn filter_button_active_style(_theme: &Theme, status: button::Status) -> button:
button::Style {
background: Some(Background::Color(bg)),
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()
}
}
@@ -773,7 +775,7 @@ pub fn view(
is_transient: true,
is_dirty: false,
}))
.padding([3, 6])
.padding([5, 8])
.width(Length::Fill)
.style(style_fn)
.into()
@@ -1025,7 +1027,7 @@ pub fn view(
is_transient: true,
is_dirty: false,
}))
.padding([3, 6])
.padding([5, 8])
.width(Length::Fill)
.style(style_fn)
.into()
@@ -1065,7 +1067,7 @@ pub fn view(
is_transient: true,
is_dirty: false,
}))
.padding([3, 6])
.padding([5, 8])
.width(Length::Fill)
.style(style_fn)
.into()
@@ -1106,7 +1108,7 @@ pub fn view(
};
button(container(label_text).width(Length::Fill))
.on_press(msg)
.padding([3, 6])
.padding([5, 8])
.width(Length::Fill)
.style(item_style)
.into()
@@ -1130,7 +1132,7 @@ pub fn view(
is_transient: false,
is_dirty: false,
}))
.padding([3, 6])
.padding([5, 8])
.width(Length::Fill)
.style(item_style)
.into()

View File

@@ -5,6 +5,7 @@ use iced::{Background, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::t;
#[derive(Debug, Clone, Default)]
@@ -25,6 +26,8 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a,
.size(13)
.shaping(Shaping::Advanced),
)
.style(inputs::primary_button)
.padding([6, 16])
} else {
button(
text(t(locale, "siteValidation.run"))
@@ -32,6 +35,8 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a,
.shaping(Shaping::Advanced),
)
.on_press(Message::RunSiteValidation)
.style(inputs::primary_button)
.padding([6, 16])
};
let has_issues = !state.missing_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)
.shaping(Shaping::Advanced),
)
.style(inputs::secondary_button)
.padding([6, 16])
} else if !state.is_running && state.error_message.is_none() && has_issues {
button(
text(t(locale, "siteValidation.apply"))
@@ -49,12 +56,16 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a,
.shaping(Shaping::Advanced),
)
.on_press(Message::ApplySiteValidation)
.style(inputs::primary_button)
.padding([6, 16])
} else {
button(
text(t(locale, "siteValidation.apply"))
.size(13)
.shaping(Shaping::Advanced),
)
.style(inputs::secondary_button)
.padding([6, 16])
};
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> {
container(
inputs::card(
text(value)
.size(14)
.shaping(Shaping::Advanced)
.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()
}
@@ -156,7 +162,7 @@ fn section<'a>(title: String, entries: &'a [String], accent: Color) -> Element<'
)
});
container(
inputs::card(
column![
text(title.to_string())
.size(16)
@@ -166,10 +172,5 @@ fn section<'a>(title: String, entries: &'a [String], accent: Color) -> Element<'
]
.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()
}

View File

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

View File

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

View File

@@ -117,30 +117,32 @@ pub enum TagsMsg {
/// Render the tags management view.
pub fn view<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> {
let section_nav = row![
section_tab(
&t(locale, "tags.nav.cloud"),
state.section == TagsSection::Cloud,
TagsSection::Cloud
),
section_tab(
&t(locale, "tags.nav.manage"),
state.section == TagsSection::Manage,
TagsSection::Manage
),
section_tab(
&t(locale, "tags.nav.merge"),
state.section == TagsSection::Merge,
TagsSection::Merge
),
section_tab(
&t(locale, "tags.nav.discover"),
state.section == TagsSection::Discover,
TagsSection::Discover
),
]
.spacing(4)
.padding(8);
let section_nav = inputs::card(
row![
section_tab(
&t(locale, "tags.nav.cloud"),
state.section == TagsSection::Cloud,
TagsSection::Cloud
),
section_tab(
&t(locale, "tags.nav.manage"),
state.section == TagsSection::Manage,
TagsSection::Manage
),
section_tab(
&t(locale, "tags.nav.merge"),
state.section == TagsSection::Merge,
TagsSection::Merge
),
section_tab(
&t(locale, "tags.nav.discover"),
state.section == TagsSection::Discover,
TagsSection::Discover
),
]
.spacing(6),
)
.padding(6);
let content: Element<'a, Message> = match state.section {
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]
.spacing(4)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn section_tab<'a>(label: &str, active: bool, section: TagsSection) -> Element<'a, Message> {
let color = if active {
Color::WHITE
} else {
Color::from_rgb(0.55, 0.58, 0.65)
};
button(text(label.to_string()).size(13).color(color))
button(text(label.to_string()).size(13))
.on_press(Message::Tags(TagsMsg::SetSection(section)))
.padding([6, 12])
.style(move |_theme: &Theme, _status| {
if active {
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 {
button::Style::default()
}
.style(if active {
inputs::primary_button
} else {
inputs::secondary_button
})
.into()
}
@@ -212,8 +201,8 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
} else {
1.0
};
const MIN_FONT: f32 = 11.0;
const MAX_FONT: f32 = 24.0;
const MIN_FONT: f32 = 12.0;
const MAX_FONT: f32 = 18.0;
let chips: Vec<Element<'a, Message>> = state
.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())))
.padding([vert_pad, 10])
.style(move |_: &Theme, _| button::Style {
background: Some(Background::Color(color)),
.style(move |_: &Theme, status| button::Style {
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 {
radius: 12.0.into(),
width: if selected { 2.0 } else { 0.0 },
color: if selected {
Color::WHITE
} else {
Color::TRANSPARENT
},
width: if selected { 2.0 } else { 1.0 },
color,
},
text_color: Color::WHITE,
..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)),
button(text(t(locale, "tags.clearSelection")).size(12))
.on_press(Message::Tags(TagsMsg::ClearSelection))
.style(inputs::secondary_button)
.padding([4, 8]),
]
.spacing(8)
@@ -285,13 +276,15 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
};
scrollable(
column![
inputs::section_header(&t(locale, "tags.cloudSection")),
selection_summary,
row(chips).spacing(6).wrap(),
]
.spacing(12)
.width(Length::Fill)
column![inputs::card(
column![
inputs::section_header(&t(locale, "tags.cloudSection")),
selection_summary,
row(chips).spacing(6).wrap(),
]
.spacing(12)
.width(Length::Fill),
)]
.padding(16),
)
.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)
.on_input(|value| Message::Tags(TagsMsg::SearchChanged(value)))
.size(14);
.size(14)
.padding([8, 10])
.style(inputs::field_style);
let filtered: Vec<&Tag> = state
.tags
@@ -446,11 +441,16 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
scrollable(
column![
create_panel,
inputs::section_header(&t(locale, "tags.manageSection")),
search,
tag_list,
edit_panel,
inputs::card(create_panel),
inputs::card(
column![
inputs::section_header(&t(locale, "tags.manageSection")),
search,
tag_list,
edit_panel,
]
.spacing(12)
),
]
.spacing(12)
.padding(16)
@@ -491,56 +491,62 @@ fn view_merge<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
})
.unwrap_or_default();
column![
inputs::section_header(&t(locale, "tags.mergeSection")),
text(tw(
locale,
"tags.selectedCount",
&[("count", &state.selected_tags.len().to_string())]
))
.size(12)
.color(Color::from_rgb(0.75, 0.77, 0.82)),
inputs::labeled_select(
&t(locale, "tags.mergeTarget"),
&tag_options,
selected_target,
|choice| Message::Tags(TagsMsg::SetMergeTarget(choice)),
),
if merge_preview.is_empty() {
Element::from(Space::new(0, 0))
} else {
container(text(tw(locale, "tags.mergePreview", &[("tags", &merge_preview)])).size(12))
container(inputs::card(
column![
inputs::section_header(&t(locale, "tags.mergeSection")),
text(tw(
locale,
"tags.selectedCount",
&[("count", &state.selected_tags.len().to_string())]
))
.size(12)
.color(Color::from_rgb(0.75, 0.77, 0.82)),
inputs::labeled_select(
&t(locale, "tags.mergeTarget"),
&tag_options,
selected_target,
|choice| Message::Tags(TagsMsg::SetMergeTarget(choice)),
),
if merge_preview.is_empty() {
Element::from(Space::new(0, 0))
} else {
container(
text(tw(locale, "tags.mergePreview", &[("tags", &merge_preview)])).size(12),
)
.padding([4, 0])
.into()
},
button(text(t(locale, "tags.merge")).size(13))
.on_press_maybe(
state
.merge_target
.is_some()
.then_some(Message::Tags(TagsMsg::MergeTags))
)
.style(inputs::primary_button)
.padding([6, 16]),
]
.spacing(12)
},
button(text(t(locale, "tags.merge")).size(13))
.on_press_maybe(
state
.merge_target
.is_some()
.then_some(Message::Tags(TagsMsg::MergeTags))
)
.style(inputs::primary_button)
.padding([6, 16]),
]
.spacing(12),
))
.padding(16)
.width(Length::Fill)
.into()
}
fn view_discover<'a>(_state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> {
column![
inputs::section_header(&t(locale, "tags.discoverSection")),
text(t(locale, "tags.discoverDescription"))
.size(12)
.color(Color::from_rgb(0.60, 0.60, 0.65)),
button(text(t(locale, "tags.discoverButton")).size(13))
.on_press(Message::Tags(TagsMsg::SyncTags))
.style(inputs::primary_button)
.padding([6, 16]),
]
.spacing(12)
container(inputs::card(
column![
inputs::section_header(&t(locale, "tags.discoverSection")),
text(t(locale, "tags.discoverDescription"))
.size(12)
.color(Color::from_rgb(0.60, 0.60, 0.65)),
button(text(t(locale, "tags.discoverButton")).size(13))
.on_press(Message::Tags(TagsMsg::SyncTags))
.style(inputs::primary_button)
.padding([6, 16]),
]
.spacing(12),
))
.padding(16)
.width(Length::Fill)
.into()

View File

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

View File

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