diff --git a/README.md b/README.md index 91262b9..f3121e9 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The project is under active development. Core blogging workflows are broadly ava ## Available Features -- Native Iced desktop workspace with a localized native macOS menu and in-window Linux/Windows menu bar, tabs, anchored editor popovers, automatically paged post/media sidebars with locale-aware post dates, calendar months, and relative-dated entity lists, row deletion, dialogs, embedded Wry previews, a live Pico CSS theme editor, direct Preferences navigation, and per-project restart restoration of the active activity, shell visibility, and open editor tabs. The in-window menu supports Alt mnemonics and arrow-key navigation. Its shared task UI keeps queued work off blocking workers, presents active local and remote work plus recent history in bDS2-style grouped progress cards, and supports cooperative cancellation without oversubscribing background work. +- Native Iced desktop workspace with a localized native macOS menu and in-window Linux/Windows menu bar, tabs, anchored editor popovers, automatically paged post/media sidebars with locale-aware post dates, calendar months, and relative-dated entity lists, row deletion, dialogs, embedded Wry previews, a live Pico CSS theme editor, direct Preferences navigation, and per-project restart restoration of the active activity, shell visibility, and open editor tabs. Every enabled control participates in Tab/Shift-Tab focus traversal and Enter/Space activation; holding Control+Option on macOS or Control+Alt elsewhere reveals direct shortcuts plus stable two/three-letter codes for visible generic controls, filters those codes as they are typed, and lets Escape cancel while Option/Alt alone remains available for character entry. The in-window menu supports Alt mnemonics and arrow-key navigation. Its shared task UI keeps queued work off blocking workers, presents active local and remote work plus recent history in bDS2-style grouped progress cards, and supports cooperative cancellation without oversubscribing background work. - Post and translation authoring with change-aware draft/published/archive lifecycle, file-backed change discard, canonical draft reopening after manual translation edits, non-disruptive automatic translation, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, cursor-preserving link and media insertion, live link/backlink graphs, media, and batch gallery-image import. - Media import including HEIC/HEIF decoding, q80 WebP thumbnails (plus q85 AI JPEGs), metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors. - WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping. diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 1b63aec..6301b54 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -238,6 +238,7 @@ pub enum Message { // Settings SetOfflineMode(bool), SetUiLocale(UiLocale), + KeyboardNavigation(crate::components::keyboard::Navigation), ToggleLocaleDropdown, ToggleProjectDropdown, @@ -1457,6 +1458,10 @@ impl BdsApp { pub fn update(&mut self, message: Message) -> Task { match message { + Message::KeyboardNavigation(navigation) => match navigation { + crate::components::keyboard::Navigation::Next => iced::widget::focus_next(), + crate::components::keyboard::Navigation::Previous => iced::widget::focus_previous(), + }, Message::WindowCloseRequested => { self.persist_project_ui_state(); flush_embeddings_and_exit(std::process::exit) @@ -3869,7 +3874,11 @@ impl BdsApp { ) && (action != MenuAction::DisconnectServer || self.remote_client.is_some()) }); - native_edit::native_edit(content, Arc::clone(&self.native_edit_commands)).into() + crate::components::keyboard::scope(native_edit::native_edit( + content, + Arc::clone(&self.native_edit_commands), + )) + .into() } pub fn subscription(&self) -> Subscription { @@ -3894,6 +3903,15 @@ impl BdsApp { _ => None, }); let window_close_sub = window::close_requests().map(|_| Message::WindowCloseRequested); + let keyboard_navigation_sub = iced::event::listen_with(|event, status, _id| { + let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { key, modifiers, .. }) = + event + else { + return None; + }; + crate::components::keyboard::tab_navigation(&key, modifiers, status) + .map(Message::KeyboardNavigation) + }); // Global mouse tracking for sidebar resize dragging. // The 4px drag handle mouse_area only fires on_press; move/release @@ -3950,6 +3968,7 @@ impl BdsApp { toast_tick, file_drop_sub, window_close_sub, + keyboard_navigation_sub, drag_sub, menu_interaction_sub, menu_expand_tick, diff --git a/crates/bds-ui/src/components/inputs.rs b/crates/bds-ui/src/components/inputs.rs index a89b68e..3e12fab 100644 --- a/crates/bds-ui/src/components/inputs.rs +++ b/crates/bds-ui/src/components/inputs.rs @@ -5,6 +5,8 @@ use iced::widget::{ }; use iced::{Alignment, Background, Border, Color, Element, Length, Shadow, Theme, Vector}; +use super::keyboard; + /// Standard form field label color. pub const LABEL_COLOR: Color = rgb8(0xB5, 0xBA, 0xC4); pub const SECTION_COLOR: Color = rgb8(0x9D, 0xA5, 0xB4); @@ -243,10 +245,13 @@ where .size(12) .color(LABEL_COLOR) .shaping(Shaping::Advanced), - pick_list(list, selected.cloned(), on_select) - .padding([8, 10]) - .width(Length::Fill) - .style(select_style), + keyboard::focusable( + pick_list(list, selected.cloned(), on_select) + .padding([8, 10]) + .width(Length::Fill) + .style(select_style), + true, + ), ] .spacing(6) .width(Length::Fill) @@ -259,11 +264,14 @@ pub fn labeled_checkbox<'a, Message: Clone + 'a>( is_checked: bool, on_toggle: impl Fn(bool) -> Message + 'a, ) -> Element<'a, Message> { - checkbox(label, is_checked) - .on_toggle(on_toggle) - .size(16) - .text_size(14) - .into() + keyboard::focusable( + checkbox(label, is_checked) + .on_toggle(on_toggle) + .size(16) + .text_size(14), + true, + ) + .into() } /// A section header with optional separator line. diff --git a/crates/bds-ui/src/components/keyboard.rs b/crates/bds-ui/src/components/keyboard.rs new file mode 100644 index 0000000..8fcaafa --- /dev/null +++ b/crates/bds-ui/src/components/keyboard.rs @@ -0,0 +1,1329 @@ +#[cfg(test)] +mod tests { + use super::*; + use iced::keyboard::{Key, Modifiers, key}; + + #[test] + fn generic_codes_are_fixed_width_and_skip_direct_key_prefixes() { + let reserved = letter_mask(['A', 'C']); + assert_eq!(code_width(24 * 26, reserved), 2); + assert_eq!(code_width(24 * 26 + 1, reserved), 3); + assert_eq!(generic_code(0, 2, reserved), "BA"); + assert_eq!(generic_code(25, 2, reserved), "BZ"); + assert_eq!(generic_code(26, 2, reserved), "DA"); + } + + #[test] + fn tab_navigation_ignores_events_already_captured_by_an_editor() { + let tab = Key::Named(key::Named::Tab); + assert_eq!( + tab_navigation(&tab, Modifiers::default(), iced::event::Status::Ignored), + Some(Navigation::Next) + ); + assert_eq!( + tab_navigation(&tab, Modifiers::SHIFT, iced::event::Status::Ignored), + Some(Navigation::Previous) + ); + assert_eq!( + tab_navigation(&tab, Modifiers::default(), iced::event::Status::Captured), + None + ); + } + + #[test] + fn access_characters_use_the_unmodified_key() { + assert_eq!(access_character(&Key::Character("m".into())), Some('M')); + assert_eq!(access_character(&Key::Character("1".into())), Some('1')); + assert_eq!(access_character(&Key::Character("µ".into())), None); + } + + #[test] + fn access_mode_requires_control_and_option_or_alt_together() { + assert!(!access_modifiers(Modifiers::ALT)); + assert!(!access_modifiers(Modifiers::CTRL)); + assert!(access_modifiers(Modifiers::CTRL | Modifiers::ALT)); + } + + #[test] + fn filtered_hints_show_only_the_remaining_code() { + assert_eq!(filtered_hint("AA", ""), Some("AA")); + assert_eq!(filtered_hint("AA", "A"), Some("A")); + assert_eq!(filtered_hint("BA", "A"), None); + assert_eq!(filtered_hint("AA", "AA"), None); + } + + #[test] + fn access_inventory_excludes_disabled_and_offscreen_controls() { + let mut visible = State { + enabled: true, + visible: Cell::new(true), + ..State::default() + }; + let mut offscreen = State { + enabled: true, + visible: Cell::new(false), + ..State::default() + }; + let mut disabled = State { + enabled: false, + visible: Cell::new(true), + ..State::default() + }; + let mut inventory = AccessKeyInventory::default(); + inventory.custom(&mut visible, None); + inventory.custom(&mut offscreen, None); + inventory.custom(&mut disabled, None); + assert_eq!(inventory.control_count, 1); + assert_eq!(inventory.generic_count, 1); + } + + #[test] + fn key_tip_boxes_stay_inside_their_controls() { + for (bounds, length, position) in [ + ( + Rectangle::new((0.0, 0.0).into(), (48.0, 48.0).into()), + 1, + HintPosition::BottomTrailing, + ), + ( + Rectangle::new((10.0, 20.0).into(), (60.0, 22.0).into()), + 2, + HintPosition::Trailing, + ), + ] { + let hint = hint_bounds(bounds, length, position).unwrap(); + assert!(hint.x >= bounds.x && hint.y >= bounds.y); + assert!(hint.x + hint.width <= bounds.x + bounds.width); + assert!(hint.y + hint.height <= bounds.y + bounds.height); + } + } + + #[test] + fn access_prefix_filters_then_resolves_one_complete_code() { + let mut first = State { + code: Some("AA".into()), + access_mode: true, + ..State::default() + }; + let mut second = State { + code: Some("AB".into()), + access_mode: true, + ..State::default() + }; + let mut prefix = AccessKeyMatches { + candidate: "A", + count: 0, + exact: None, + }; + prefix.custom(&mut first, None); + prefix.custom(&mut second, None); + assert_eq!(prefix.count, 2); + assert_eq!(prefix.exact, None); + + let mut complete = AccessKeyMatches { + candidate: "AA", + count: 0, + exact: None, + }; + complete.custom(&mut first, None); + complete.custom(&mut second, None); + assert_eq!(complete.count, 1); + assert_eq!(complete.exact.as_deref(), Some("AA")); + } +} +use iced::advanced::layout; +use iced::advanced::overlay; +use iced::advanced::renderer; +use iced::advanced::text::{self, Renderer as _}; +use iced::advanced::widget::operation::Focusable; +use iced::advanced::widget::{Id, Operation, Tree, tree}; +use iced::advanced::{Clipboard, Layout, Renderer as _, Shell, Widget}; +use iced::event; +use iced::keyboard::{self, Key, Modifiers, key}; +use iced::mouse; +use iced::widget::{Button, button as iced_button}; +use iced::{ + Background, Border, Color, Element, Event, Length, Padding, Pixels, Rectangle, Size, Theme, + Vector, alignment, +}; +use std::any::Any; +use std::cell::Cell; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Navigation { + Next, + Previous, +} + +pub fn tab_navigation( + key: &Key, + modifiers: Modifiers, + status: event::Status, +) -> Option { + (status == event::Status::Ignored && matches!(key, Key::Named(key::Named::Tab))).then(|| { + if modifiers.shift() { + Navigation::Previous + } else { + Navigation::Next + } + }) +} + +fn access_character(key: &Key) -> Option { + let Key::Character(value) = key else { + return None; + }; + let mut characters = value.chars(); + let key = characters.next()?; + characters + .next() + .is_none() + .then_some(key) + .filter(char::is_ascii_alphanumeric) + .map(|key| key.to_ascii_uppercase()) +} + +fn access_modifiers(modifiers: Modifiers) -> bool { + modifiers.alt() && modifiers.control() +} + +fn letter_mask(keys: impl IntoIterator) -> u32 { + keys.into_iter().fold(0, |mask, key| { + let key = key.to_ascii_uppercase(); + if key.is_ascii_uppercase() { + mask | (1 << (key as u8 - b'A')) + } else { + mask + } + }) +} + +fn available_initials(reserved: u32) -> impl Iterator { + ('A'..='Z').filter(move |key| reserved & (1 << (*key as u8 - b'A')) == 0) +} + +fn code_width(count: usize, reserved: u32) -> usize { + let initials = available_initials(reserved).count().max(1); + if count <= initials * 26 { 2 } else { 3 } +} + +fn generic_code(index: usize, width: usize, reserved: u32) -> String { + let suffix_capacity = 26_usize.pow((width - 1) as u32); + let initial = available_initials(reserved) + .nth(index / suffix_capacity) + .expect("access-key capacity exceeded"); + let mut code = String::with_capacity(width); + code.push(initial); + let suffix = index % suffix_capacity; + for place in (0..width - 1).rev() { + let divisor = 26_usize.pow(place as u32); + code.push((b'A' + ((suffix / divisor) % 26) as u8) as char); + } + code +} + +fn filtered_hint<'a>(code: &'a str, prefix: &str) -> Option<&'a str> { + code.strip_prefix(prefix) + .filter(|remaining| !remaining.is_empty()) +} + +#[derive(Debug, Clone, Copy, Default)] +pub enum HintPosition { + #[default] + Trailing, + BottomTrailing, +} + +fn hint_bounds(bounds: Rectangle, label_len: usize, position: HintPosition) -> Option { + let inset = 2.0_f32; + let width = (12.0 + label_len as f32 * 4.0).min((bounds.width - inset * 2.0).max(0.0)); + let height = 16.0_f32.min((bounds.height - inset * 2.0).max(0.0)); + if width < 12.0 || height < 12.0 { + return None; + } + let x = bounds.x + bounds.width - width - inset; + let y = match position { + HintPosition::BottomTrailing => bounds.y + bounds.height - height - inset, + HintPosition::Trailing => bounds.center_y() - height / 2.0, + }; + Some(Rectangle { + x, + y, + width, + height, + }) +} + +#[derive(Default)] +struct State { + focused: bool, + enabled: bool, + visible: Cell, + direct_key: Option, + code: Option, + prefix: String, + access_mode: bool, + pending_activation: bool, +} + +impl Focusable for State { + fn is_focused(&self) -> bool { + self.focused + } + + fn focus(&mut self) { + self.focused = true; + } + + fn unfocus(&mut self) { + self.focused = false; + } +} + +fn visit_children( + operation: &mut dyn Operation, + operate_on_children: &mut dyn FnMut(&mut dyn Operation), +) { + operate_on_children(operation); +} + +#[derive(Default)] +struct AccessKeyInventory { + generic_count: usize, + direct_letters: u32, + control_count: usize, +} + +impl Operation for AccessKeyInventory { + fn container( + &mut self, + _id: Option<&Id>, + _bounds: Rectangle, + operate_on_children: &mut dyn FnMut(&mut dyn Operation), + ) { + visit_children(self, operate_on_children); + } + + fn custom(&mut self, state: &mut dyn Any, _id: Option<&Id>) { + let Some(state) = state.downcast_ref::() else { + return; + }; + if !state.enabled || !state.visible.get() { + return; + } + self.control_count += 1; + if let Some(key) = state.direct_key { + self.direct_letters |= letter_mask([key]); + } else { + self.generic_count += 1; + } + } +} + +struct AssignAccessKeys { + width: usize, + reserved: u32, + next_generic: usize, +} + +impl Operation for AssignAccessKeys { + fn container( + &mut self, + _id: Option<&Id>, + _bounds: Rectangle, + operate_on_children: &mut dyn FnMut(&mut dyn Operation), + ) { + visit_children(self, operate_on_children); + } + + fn custom(&mut self, state: &mut dyn Any, _id: Option<&Id>) { + let Some(state) = state.downcast_mut::() else { + return; + }; + state.prefix.clear(); + state.access_mode = state.enabled && state.visible.get(); + state.code = if !state.access_mode { + None + } else if let Some(key) = state.direct_key { + Some(key.to_ascii_uppercase().to_string()) + } else { + let code = generic_code(self.next_generic, self.width, self.reserved); + self.next_generic += 1; + Some(code) + }; + } +} + +struct SetAccessPrefix<'a> { + prefix: &'a str, +} + +impl Operation for SetAccessPrefix<'_> { + fn container( + &mut self, + _id: Option<&Id>, + _bounds: Rectangle, + operate_on_children: &mut dyn FnMut(&mut dyn Operation), + ) { + visit_children(self, operate_on_children); + } + + fn custom(&mut self, state: &mut dyn Any, _id: Option<&Id>) { + if let Some(state) = state.downcast_mut::() + && state.access_mode + { + state.prefix.clear(); + state.prefix.push_str(self.prefix); + } + } +} + +struct CancelAccessKeys; + +impl Operation for CancelAccessKeys { + fn container( + &mut self, + _id: Option<&Id>, + _bounds: Rectangle, + operate_on_children: &mut dyn FnMut(&mut dyn Operation), + ) { + visit_children(self, operate_on_children); + } + + fn custom(&mut self, state: &mut dyn Any, _id: Option<&Id>) { + if let Some(state) = state.downcast_mut::() { + state.access_mode = false; + state.prefix.clear(); + } + } +} + +struct AccessKeyMatches<'a> { + candidate: &'a str, + count: usize, + exact: Option, +} + +impl Operation for AccessKeyMatches<'_> { + fn container( + &mut self, + _id: Option<&Id>, + _bounds: Rectangle, + operate_on_children: &mut dyn FnMut(&mut dyn Operation), + ) { + visit_children(self, operate_on_children); + } + + fn custom(&mut self, state: &mut dyn Any, _id: Option<&Id>) { + let Some(state) = state.downcast_ref::() else { + return; + }; + let Some(code) = state + .access_mode + .then_some(state.code.as_deref()) + .flatten() + .filter(|code| code.starts_with(self.candidate)) + else { + return; + }; + self.count += 1; + if code == self.candidate { + self.exact = Some(code.to_owned()); + } + } +} + +struct FocusAccessKey<'a> { + target: &'a str, + next_is_target: bool, +} + +impl Operation for FocusAccessKey<'_> { + fn container( + &mut self, + _id: Option<&Id>, + _bounds: Rectangle, + operate_on_children: &mut dyn FnMut(&mut dyn Operation), + ) { + visit_children(self, operate_on_children); + } + + fn custom(&mut self, state: &mut dyn Any, _id: Option<&Id>) { + let Some(state) = state.downcast_mut::() else { + self.next_is_target = false; + return; + }; + self.next_is_target = state.code.as_deref() == Some(self.target); + state.pending_activation = self.next_is_target; + } + + fn focusable(&mut self, state: &mut dyn Focusable, _id: Option<&Id>) { + if self.next_is_target { + state.focus(); + } else { + state.unfocus(); + } + self.next_is_target = false; + } +} + +#[derive(Default)] +struct FocusedEditable { + next_is_control: bool, + found: bool, +} + +impl Operation for FocusedEditable { + fn container( + &mut self, + _id: Option<&Id>, + _bounds: Rectangle, + operate_on_children: &mut dyn FnMut(&mut dyn Operation), + ) { + visit_children(self, operate_on_children); + } + + fn custom(&mut self, state: &mut dyn Any, _id: Option<&Id>) { + self.next_is_control = state.is::(); + } + + fn focusable(&mut self, state: &mut dyn Focusable, _id: Option<&Id>) { + self.found |= state.is_focused() && !self.next_is_control; + self.next_is_control = false; + } +} + +/// Adds native focus traversal and keyboard activation to an Iced control. +pub struct FocusableControl<'a, Message> { + content: Element<'a, Message>, + enabled: bool, + hotkey: Option, + hint_position: HintPosition, +} + +impl<'a, Message> FocusableControl<'a, Message> { + pub fn new(content: impl Into>, enabled: bool) -> Self { + Self { + content: content.into(), + enabled, + hotkey: None, + hint_position: HintPosition::Trailing, + } + } + + pub fn hotkey(mut self, hotkey: char) -> Self { + self.hotkey = Some(hotkey.to_ascii_uppercase()); + self + } + + pub fn hint_position(mut self, position: HintPosition) -> Self { + self.hint_position = position; + self + } + + fn activate( + &mut self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &iced::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + let cursor = mouse::Cursor::Available(layout.bounds().center()); + for event in [ + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ] { + let _ = self.content.as_widget_mut().on_event( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + } +} + +impl Widget for FocusableControl<'_, Message> { + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn children(&self) -> Vec { + vec![Tree::new(&self.content)] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(std::slice::from_ref(&self.content)); + let state = tree.state.downcast_mut::(); + state.enabled = self.enabled; + state.direct_key = self.hotkey; + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn layout( + &self, + tree: &mut Tree, + renderer: &iced::Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget() + .layout(&mut tree.children[0], renderer, limits) + } + + fn operate( + &self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &iced::Renderer, + operation: &mut dyn Operation, + ) { + let state = tree.state.downcast_mut::(); + state.enabled = self.enabled; + state.direct_key = self.hotkey; + operation.custom(state, None); + if self.enabled { + operation.focusable(state, None); + } else { + state.unfocus(); + } + operation.container(None, layout.bounds(), &mut |operation| { + self.content + .as_widget() + .operate(&mut tree.children[0], layout, renderer, operation); + }); + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &iced::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) -> event::Status { + let pending_activation = { + let state = tree.state.downcast_mut::(); + let pending = state.pending_activation; + state.pending_activation = false; + pending + }; + if pending_activation && self.enabled { + self.activate(tree, layout, renderer, clipboard, shell, viewport); + return event::Status::Captured; + } + let child_status = self.content.as_widget_mut().on_event( + &mut tree.children[0], + event.clone(), + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + let state = tree.state.downcast_mut::(); + if child_status == event::Status::Captured || !self.enabled { + return child_status; + } + let activate = state.focused + && matches!( + &event, + Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(key::Named::Enter) | Key::Named(key::Named::Space), + .. + }) + ); + if activate { + self.activate(tree, layout, renderer, clipboard, shell, viewport); + event::Status::Captured + } else { + child_status + } + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &iced::Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut iced::Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + let state = tree.state.downcast_ref::(); + let bounds = layout.bounds(); + state.visible.set( + bounds + .intersection(viewport) + .is_some_and(|visible| visible.width > 0.0 && visible.height > 0.0), + ); + if state.focused { + renderer.fill_quad( + renderer::Quad { + bounds, + border: Border { + color: Color::from_rgb8(0x00, 0x7F, 0xD4), + width: 2.0, + radius: 6.0.into(), + }, + ..renderer::Quad::default() + }, + Background::Color(Color::TRANSPARENT), + ); + } + if let Some(label) = state + .access_mode + .then_some(state.code.as_deref()) + .flatten() + .and_then(|code| filtered_hint(code, &state.prefix)) + { + let Some(badge) = hint_bounds(bounds, label.len(), self.hint_position) else { + return; + }; + renderer.fill_quad( + renderer::Quad { + bounds: badge, + border: Border { + color: Color::BLACK, + width: 1.0, + radius: 4.0.into(), + }, + ..renderer::Quad::default() + }, + Background::Color(Color::from_rgb8(0xF2, 0xC1, 0x4E)), + ); + renderer.fill_text( + text::Text { + content: label.to_owned(), + font: renderer.default_font(), + size: Pixels(9.0), + line_height: text::LineHeight::Relative(1.0), + bounds: badge.size(), + horizontal_alignment: alignment::Horizontal::Center, + vertical_alignment: alignment::Vertical::Center, + shaping: text::Shaping::Advanced, + wrapping: text::Wrapping::None, + }, + badge.center(), + Color::BLACK, + *viewport, + ); + } + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'_>, + renderer: &iced::Renderer, + translation: Vector, + ) -> Option> { + self.content + .as_widget_mut() + .overlay(&mut tree.children[0], layout, renderer, translation) + } +} + +impl<'a, Message: 'a> From> for Element<'a, Message> { + fn from(control: FocusableControl<'a, Message>) -> Self { + Element::new(control) + } +} + +pub fn focusable<'a, Message: 'a>( + content: impl Into>, + enabled: bool, +) -> FocusableControl<'a, Message> { + FocusableControl::new(content, enabled) +} + +#[derive(Default)] +struct AccessKeyScopeState { + active: bool, + access_modifiers_down: bool, + cancelled_until_modifiers_release: bool, + prefix: String, +} + +/// Owns one access-key namespace for the visible application surface. +pub struct AccessKeyScope<'a, Message> { + content: Element<'a, Message>, +} + +impl<'a, Message> AccessKeyScope<'a, Message> { + fn new(content: impl Into>) -> Self { + Self { + content: content.into(), + } + } + + fn operate( + &self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &iced::Renderer, + operation: &mut dyn Operation, + ) { + self.content + .as_widget() + .operate(&mut tree.children[0], layout, renderer, operation); + } + + fn begin(&self, tree: &mut Tree, layout: Layout<'_>, renderer: &iced::Renderer) -> bool { + let mut focused_editable = FocusedEditable::default(); + self.operate(tree, layout, renderer, &mut focused_editable); + if focused_editable.found { + return false; + } + + let mut inventory = AccessKeyInventory::default(); + self.operate(tree, layout, renderer, &mut inventory); + if inventory.control_count == 0 { + return false; + } + let mut assign = AssignAccessKeys { + width: code_width(inventory.generic_count, inventory.direct_letters), + reserved: inventory.direct_letters, + next_generic: 0, + }; + self.operate(tree, layout, renderer, &mut assign); + true + } + + fn cancel(&self, tree: &mut Tree, layout: Layout<'_>, renderer: &iced::Renderer) { + self.operate(tree, layout, renderer, &mut CancelAccessKeys); + } +} + +impl Widget for AccessKeyScope<'_, Message> { + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(AccessKeyScopeState::default()) + } + + fn children(&self) -> Vec { + vec![Tree::new(&self.content)] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(std::slice::from_ref(&self.content)); + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn layout( + &self, + tree: &mut Tree, + renderer: &iced::Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget() + .layout(&mut tree.children[0], renderer, limits) + } + + fn operate( + &self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &iced::Renderer, + operation: &mut dyn Operation, + ) { + operation.container(None, layout.bounds(), &mut |operation| { + self.content + .as_widget() + .operate(&mut tree.children[0], layout, renderer, operation); + }); + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &iced::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) -> event::Status { + let modifier_update = match &event { + Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) + | Event::Keyboard(keyboard::Event::KeyPressed { modifiers, .. }) + | Event::Keyboard(keyboard::Event::KeyReleased { modifiers, .. }) => Some(*modifiers), + _ => None, + }; + if let Some(modifiers) = modifier_update { + let (were_down, cancelled) = { + let state = tree.state.downcast_ref::(); + ( + state.access_modifiers_down, + state.cancelled_until_modifiers_release, + ) + }; + let are_down = access_modifiers(modifiers); + if are_down && !were_down && !cancelled { + let active = self.begin(tree, layout, renderer); + let state = tree.state.downcast_mut::(); + state.active = active; + state.prefix.clear(); + } else if !are_down && were_down { + self.cancel(tree, layout, renderer); + let state = tree.state.downcast_mut::(); + state.active = false; + state.prefix.clear(); + state.cancelled_until_modifiers_release = false; + } + tree.state + .downcast_mut::() + .access_modifiers_down = are_down; + } + + if let Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Named(key::Named::Escape), + .. + }) = &event + && tree.state.downcast_ref::().active + { + self.cancel(tree, layout, renderer); + let state = tree.state.downcast_mut::(); + state.active = false; + state.prefix.clear(); + state.cancelled_until_modifiers_release = true; + return event::Status::Captured; + } + + if let Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) = &event + && access_modifiers(*modifiers) + && let Some(key) = access_character(key) + { + let (mut active, cancelled, prefix) = { + let state = tree.state.downcast_ref::(); + ( + state.active, + state.cancelled_until_modifiers_release, + state.prefix.clone(), + ) + }; + if cancelled { + return event::Status::Captured; + } + if !active { + active = self.begin(tree, layout, renderer); + if !active { + return self.content.as_widget_mut().on_event( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + let state = tree.state.downcast_mut::(); + state.active = true; + state.prefix.clear(); + } + + let candidate = format!("{prefix}{key}"); + let mut matches = AccessKeyMatches { + candidate: &candidate, + count: 0, + exact: None, + }; + self.operate(tree, layout, renderer, &mut matches); + let count = matches.count; + let exact = matches.exact.take(); + drop(matches); + if count == 0 { + return event::Status::Captured; + } + + self.operate( + tree, + layout, + renderer, + &mut SetAccessPrefix { prefix: &candidate }, + ); + tree.state.downcast_mut::().prefix = candidate; + + if let Some(target) = exact { + self.operate( + tree, + layout, + renderer, + &mut FocusAccessKey { + target: &target, + next_is_target: false, + }, + ); + let _ = self.content.as_widget_mut().on_event( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + self.cancel(tree, layout, renderer); + let state = tree.state.downcast_mut::(); + state.active = false; + state.prefix.clear(); + state.cancelled_until_modifiers_release = true; + } + return event::Status::Captured; + } + + if matches!(event, Event::Mouse(mouse::Event::ButtonPressed(_))) + && tree.state.downcast_ref::().active + { + self.cancel(tree, layout, renderer); + let state = tree.state.downcast_mut::(); + state.active = false; + state.prefix.clear(); + } + + self.content.as_widget_mut().on_event( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ) + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &iced::Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut iced::Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'_>, + renderer: &iced::Renderer, + translation: Vector, + ) -> Option> { + self.content + .as_widget_mut() + .overlay(&mut tree.children[0], layout, renderer, translation) + } +} + +impl<'a, Message: 'a> From> for Element<'a, Message> { + fn from(scope: AccessKeyScope<'a, Message>) -> Self { + Element::new(scope) + } +} + +pub fn scope<'a, Message: 'a>( + content: impl Into>, +) -> AccessKeyScope<'a, Message> { + AccessKeyScope::new(content) +} + +/// Keeps covered content out of keyboard traversal while a blocking overlay is open. +pub struct SuspendedKeyboard<'a, Message> { + content: Element<'a, Message>, +} + +impl Widget for SuspendedKeyboard<'_, Message> { + fn children(&self) -> Vec { + vec![Tree::new(&self.content)] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(std::slice::from_ref(&self.content)); + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn layout( + &self, + tree: &mut Tree, + renderer: &iced::Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget() + .layout(&mut tree.children[0], renderer, limits) + } + + fn operate( + &self, + _tree: &mut Tree, + _layout: Layout<'_>, + _renderer: &iced::Renderer, + _operation: &mut dyn Operation, + ) { + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &iced::Renderer, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) -> event::Status { + if matches!(event, Event::Keyboard(_)) { + event::Status::Captured + } else { + self.content.as_widget_mut().on_event( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ) + } + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &iced::Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut iced::Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'_>, + renderer: &iced::Renderer, + translation: Vector, + ) -> Option> { + self.content + .as_widget_mut() + .overlay(&mut tree.children[0], layout, renderer, translation) + } +} + +impl<'a, Message: 'a> From> for Element<'a, Message> { + fn from(suspended: SuspendedKeyboard<'a, Message>) -> Self { + Element::new(suspended) + } +} + +pub fn suspend<'a, Message: 'a>( + content: impl Into>, +) -> SuspendedKeyboard<'a, Message> { + SuspendedKeyboard { + content: content.into(), + } +} + +pub struct KeyboardButton<'a, Message> { + button: Button<'a, Message>, + enabled: bool, + hotkey: Option, + hint_position: HintPosition, +} + +impl<'a, Message: Clone + 'a> KeyboardButton<'a, Message> { + pub fn width(mut self, width: impl Into) -> Self { + self.button = self.button.width(width); + self + } + + pub fn height(mut self, height: impl Into) -> Self { + self.button = self.button.height(height); + self + } + + pub fn padding(mut self, padding: impl Into) -> Self { + self.button = self.button.padding(padding); + self + } + + pub fn on_press(mut self, message: Message) -> Self { + self.button = self.button.on_press(message); + self.enabled = true; + self + } + + pub fn on_press_with(mut self, message: impl Fn() -> Message + 'a) -> Self { + self.button = self.button.on_press_with(message); + self.enabled = true; + self + } + + pub fn on_press_maybe(mut self, message: Option) -> Self { + self.enabled = message.is_some(); + self.button = self.button.on_press_maybe(message); + self + } + + pub fn clip(mut self, clip: bool) -> Self { + self.button = self.button.clip(clip); + self + } + + pub fn style( + mut self, + style: impl Fn(&Theme, iced::widget::button::Status) -> iced::widget::button::Style + 'a, + ) -> Self { + self.button = self.button.style(style); + self + } + + pub fn hotkey(mut self, hotkey: char) -> Self { + self.hotkey = Some(hotkey); + self + } + + pub fn hint_position(mut self, position: HintPosition) -> Self { + self.hint_position = position; + self + } +} + +impl<'a, Message: Clone + 'a> From> for Element<'a, Message> { + fn from(button: KeyboardButton<'a, Message>) -> Self { + let control = FocusableControl::new(button.button, button.enabled) + .hint_position(button.hint_position); + match button.hotkey { + Some(hotkey) => control.hotkey(hotkey).into(), + None => control.into(), + } + } +} + +pub fn button<'a, Message: Clone + 'a>( + content: impl Into>, +) -> KeyboardButton<'a, Message> { + KeyboardButton { + button: iced_button(content), + enabled: false, + hotkey: None, + hint_position: HintPosition::Trailing, + } +} diff --git a/crates/bds-ui/src/components/mod.rs b/crates/bds-ui/src/components/mod.rs index 60f3e5d..650c0aa 100644 --- a/crates/bds-ui/src/components/mod.rs +++ b/crates/bds-ui/src/components/mod.rs @@ -1,4 +1,5 @@ pub mod inputs; +pub mod keyboard; pub mod native_edit; pub mod popover; pub mod webview; diff --git a/crates/bds-ui/src/platform/menu.rs b/crates/bds-ui/src/platform/menu.rs index 5a08c1c..7014374 100644 --- a/crates/bds-ui/src/platform/menu.rs +++ b/crates/bds-ui/src/platform/menu.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use iced::widget::{ - Column, button, column, container, horizontal_space, mouse_area, row, scrollable, text, + Column, column, container, horizontal_space, mouse_area, row, scrollable, text, }; use iced::{ Alignment, Background, Border, Color, Element, Length, Shadow, Subscription, Theme, Vector, @@ -10,7 +10,7 @@ use muda::accelerator::{Accelerator, CMD_OR_CTRL, Code, Modifiers}; use muda::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu}; use crate::app::Message; -use crate::components::{inputs, popover}; +use crate::components::{inputs, keyboard, popover}; use crate::state::tabs::TabType; use bds_core::i18n::{UiLocale, translate}; @@ -640,9 +640,12 @@ fn window_menu_popup<'a>( ] .align_y(Alignment::Center) .spacing(16); - let mut item = button(content).padding([6, 8]).width(Length::Fill).style( - move |theme, status| menu_item_style(selected == Some(action), theme, status), - ); + let mut item = keyboard::button(content) + .padding([6, 8]) + .width(Length::Fill) + .style(move |theme, status| { + menu_item_style(selected == Some(action), theme, status) + }); if is_enabled { item = item.on_press(Message::WindowMenu(WindowMenuEvent::Action(action))); } @@ -692,7 +695,7 @@ pub fn window_menu_view<'a>( label }; let trigger = mouse_area( - button(text(label).size(12)) + keyboard::button(text(label).size(12)) .padding([5, 8]) .style(move |theme, status| menu_button_style(active, theme, status)) .on_press(Message::WindowMenu(WindowMenuEvent::Toggle( diff --git a/crates/bds-ui/src/views/activity_bar.rs b/crates/bds-ui/src/views/activity_bar.rs index 8539eeb..11efd03 100644 --- a/crates/bds-ui/src/views/activity_bar.rs +++ b/crates/bds-ui/src/views/activity_bar.rs @@ -1,5 +1,7 @@ +use crate::components::keyboard; +use iced::widget::button; use iced::widget::text::Shaping; -use iced::widget::{Column, Space, button, column, container, svg, text, tooltip}; +use iced::widget::{Column, Space, column, container, svg, text, tooltip}; use iced::{Background, Border, Color, Element, Length, Theme}; use bds_core::i18n::UiLocale; @@ -28,6 +30,21 @@ fn icon_svg(view: SidebarView) -> &'static [u8] { } } +pub fn access_key(view: SidebarView) -> char { + match view { + SidebarView::Posts => '1', + SidebarView::Pages => '2', + SidebarView::Media => '3', + SidebarView::Scripts => '4', + SidebarView::Templates => '5', + SidebarView::Tags => '6', + SidebarView::Chat => '7', + SidebarView::Import => '8', + SidebarView::Git => '9', + SidebarView::Settings => '0', + } +} + /// Top group of activity items. const TOP_ACTIVITIES: &[SidebarView] = &[ SidebarView::Posts, @@ -108,7 +125,7 @@ pub fn view( .height(Length::Fixed(24.0)) .opacity(if is_active { 1.0_f32 } else { 0.4_f32 }); - let btn = button( + let btn = iced::widget::button( container(icon) .center_x(Length::Fixed(48.0)) .center_y(Length::Fixed(48.0)), @@ -140,13 +157,18 @@ pub fn view( // Wrap in tooltip per layout.allium ActivityButton.label_key let tip_text = t(locale, view.i18n_key()); - tooltip( - btn_row, - text(tip_text).size(12).shaping(Shaping::Advanced), - tooltip::Position::Right, + keyboard::focusable( + tooltip( + btn_row, + text(tip_text).size(12).shaping(Shaping::Advanced), + tooltip::Position::Right, + ) + .gap(4) + .style(inputs::tooltip_style), + true, ) - .gap(4) - .style(inputs::tooltip_style) + .hotkey(access_key(view)) + .hint_position(keyboard::HintPosition::BottomTrailing) .into() }; diff --git a/crates/bds-ui/src/views/chat_surfaces.rs b/crates/bds-ui/src/views/chat_surfaces.rs index 879481a..fd3e6a9 100644 --- a/crates/bds-ui/src/views/chat_surfaces.rs +++ b/crates/bds-ui/src/views/chat_surfaces.rs @@ -1,3 +1,4 @@ +use crate::components::keyboard; use std::collections::{HashMap, HashSet}; use std::f32::consts::{FRAC_PI_2, TAU}; @@ -7,9 +8,7 @@ use bds_core::engine::chat_surfaces::{ }; use bds_core::i18n::UiLocale; use iced::widget::canvas::{self, Path, Stroke, path}; -use iced::widget::{ - Space, button, checkbox, column, container, row, scrollable, text, text_editor, -}; +use iced::widget::{Space, checkbox, column, container, row, scrollable, text, text_editor}; use iced::{ Alignment, Color, Element, Length, Point, Radians, Rectangle, Renderer, Size, Theme, mouse, }; @@ -76,7 +75,7 @@ fn surface_view<'a>( } if dismissible { header = header.push( - button(text(t(locale, "chat.surface.dismiss"))) + keyboard::button(text(t(locale, "chat.surface.dismiss"))) .on_press(Message::ChatSurfaceDismissed(surface.id.clone())) .padding([4, 8]) .style(inputs::secondary_button), @@ -113,7 +112,7 @@ fn surface_content<'a>( .iter() .fold(row![].spacing(8), |actions, item| { actions.push( - button(text(item.label.clone())) + keyboard::button(text(item.label.clone())) .on_press(Message::ChatSurfaceAction { surface_id: surface.id.clone(), action: item.action.clone(), @@ -196,15 +195,18 @@ fn form<'a>( let surface_id = surface.id.clone(); let key = field.key.clone(); let control: Element<'a, Message> = match field.input_type { - FormInputType::Checkbox => checkbox(label, field.value.as_bool().unwrap_or(false)) - .on_toggle(move |value| Message::ChatSurfaceFieldChanged { - surface_id: surface_id.clone(), - field: key.clone(), - value: value.into(), - }) - .size(16) - .text_size(13) - .into(), + FormInputType::Checkbox => keyboard::focusable( + checkbox(label, field.value.as_bool().unwrap_or(false)) + .on_toggle(move |value| Message::ChatSurfaceFieldChanged { + surface_id: surface_id.clone(), + field: key.clone(), + value: value.into(), + }) + .size(16) + .text_size(13), + true, + ) + .into(), FormInputType::Select => { let selected = field.options.iter().find(|option| { field @@ -278,7 +280,7 @@ fn form<'a>( } if let Some(action) = &surface.submit_action { children.push( - button(text( + keyboard::button(text( surface .submit_label .clone() @@ -437,7 +439,7 @@ fn tabs<'a>( .enumerate() .fold(row![].spacing(6), |controls, (index, tab)| { controls.push( - button(text(tab.label.clone())) + keyboard::button(text(tab.label.clone())) .on_press(Message::ChatSurfaceTabSelected { surface_id: surface.id.clone(), index, diff --git a/crates/bds-ui/src/views/chat_view.rs b/crates/bds-ui/src/views/chat_view.rs index 7287788..ff4f6af 100644 --- a/crates/bds-ui/src/views/chat_view.rs +++ b/crates/bds-ui/src/views/chat_view.rs @@ -1,3 +1,4 @@ +use crate::components::keyboard; use std::collections::HashMap; use std::time::Instant; @@ -8,7 +9,7 @@ use bds_core::i18n::UiLocale; use bds_core::model::{ChatConversation, ChatMessage, ChatRole}; use iced::widget::text::Shaping; use iced::widget::{ - Space, button, column, container, markdown, row, scrollable, text, text_editor, text_input, + Space, column, container, markdown, row, scrollable, text, text_editor, text_input, }; use iced::{Alignment, Color, Element, Length}; @@ -212,7 +213,7 @@ pub fn view<'a>( text(t(locale, "chat.unavailable.guidance")) .size(13) .color(inputs::SECTION_COLOR), - button(text(t(locale, "chat.unavailable.openSettings"))) + keyboard::button(text(t(locale, "chat.unavailable.openSettings"))) .on_press(Message::OpenSettingsSection( crate::views::settings_view::SettingsSection::AI, )) @@ -254,11 +255,11 @@ pub fn view<'a>( .size(18) .padding([7, 9]) .style(inputs::field_style), - button(text(t(locale, "chat.rename.action"))) + keyboard::button(text(t(locale, "chat.rename.action"))) .on_press(Message::ChatRename) .padding([8, 12]) .style(inputs::secondary_button), - button(text(t(locale, "common.delete"))) + keyboard::button(text(t(locale, "common.delete"))) .on_press(Message::ChatDelete(state.conversation.id.clone())) .padding([8, 12]) .style(inputs::danger_button), @@ -349,13 +350,14 @@ pub fn view<'a>( } else { t(locale, "chat.send") }; - let mut send_button = button(text(send_label)) - .padding([8, 16]) - .style(if state.streaming { - inputs::danger_button - } else { - inputs::primary_button - }); + let mut send_button = + keyboard::button(text(send_label)) + .padding([8, 16]) + .style(if state.streaming { + inputs::danger_button + } else { + inputs::primary_button + }); if state.streaming { send_button = send_button.on_press(Message::ChatCancel); } else if !state.input.text().trim().is_empty() { diff --git a/crates/bds-ui/src/views/dashboard.rs b/crates/bds-ui/src/views/dashboard.rs index 3abfa89..094976f 100644 --- a/crates/bds-ui/src/views/dashboard.rs +++ b/crates/bds-ui/src/views/dashboard.rs @@ -1,4 +1,5 @@ -use iced::widget::{Space, button, column, container, row, scrollable, text, tooltip}; +use crate::components::keyboard; +use iced::widget::{Space, column, container, row, scrollable, text, tooltip}; use iced::{Alignment, Background, Color, Element, Length, Theme}; use bds_core::i18n::UiLocale; @@ -339,7 +340,7 @@ fn recent_posts<'a>(posts: &'a [DashboardRecentPost], locale: UiLocale) -> Eleme .map(|post| { container( row![ - button( + keyboard::button( column![ text(post.title.clone()).size(14).color(Color::WHITE), text(post.date.clone()) @@ -357,7 +358,7 @@ fn recent_posts<'a>(posts: &'a [DashboardRecentPost], locale: UiLocale) -> Eleme })) .width(Length::Fill), status_badge(&post.status), - button(text(t(locale, "dashboard.pin")).size(12)) + keyboard::button(text(t(locale, "dashboard.pin")).size(12)) .on_press(Message::OpenTab(Tab { id: post.post_id.clone(), tab_type: TabType::Post, diff --git a/crates/bds-ui/src/views/documentation.rs b/crates/bds-ui/src/views/documentation.rs index d80e6f2..b3df131 100644 --- a/crates/bds-ui/src/views/documentation.rs +++ b/crates/bds-ui/src/views/documentation.rs @@ -1,3 +1,4 @@ +use crate::components::keyboard; use std::collections::{HashMap, hash_map::DefaultHasher}; use std::fs; use std::hash::{Hash, Hasher}; @@ -5,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, Instant, UNIX_EPOCH}; use bds_core::i18n::UiLocale; -use iced::widget::{button, column, container, markdown, row, scrollable, text}; +use iced::widget::{column, container, markdown, row, scrollable, text}; use iced::{Element, Length}; use crate::app::Message; @@ -170,7 +171,7 @@ pub fn view(state: &DocumentationState, locale: UiLocale) -> Element<'_, Message .into(), ], vec![ - button(text(t(locale, "common.refresh")).size(13)) + keyboard::button(text(t(locale, "common.refresh")).size(13)) .on_press(Message::DocumentationRefresh(state.kind)) .padding([6, 16]) .style(inputs::secondary_button) diff --git a/crates/bds-ui/src/views/duplicates.rs b/crates/bds-ui/src/views/duplicates.rs index 5a13fd1..daac409 100644 --- a/crates/bds-ui/src/views/duplicates.rs +++ b/crates/bds-ui/src/views/duplicates.rs @@ -1,9 +1,10 @@ +use crate::components::keyboard; use std::collections::HashSet; use bds_core::engine::embedding::DuplicateSearchResult; use bds_core::i18n::UiLocale; use iced::widget::text::Shaping; -use iced::widget::{Space, button, checkbox, column, container, row, scrollable, text}; +use iced::widget::{Space, checkbox, column, container, row, scrollable, text}; use iced::{Color, Element, Length}; use crate::app::Message; @@ -23,19 +24,20 @@ pub struct DuplicatesState { pub fn view(state: &DuplicatesState, locale: UiLocale) -> Element<'_, Message> { let refresh = if state.is_loading { - button(text(t(locale, "duplicates.searching")).size(13)).style(inputs::secondary_button) + keyboard::button(text(t(locale, "duplicates.searching")).size(13)) + .style(inputs::secondary_button) } else { - button(text(t(locale, "common.refresh")).size(13)) + keyboard::button(text(t(locale, "common.refresh")).size(13)) .on_press(Message::DuplicatesRefresh) .style(inputs::secondary_button) } .padding([6, 16]); let dismiss_checked = if state.selected.is_empty() || state.is_loading { - button(text(t(locale, "duplicates.dismissChecked")).size(13)) + keyboard::button(text(t(locale, "duplicates.dismissChecked")).size(13)) .style(inputs::secondary_button) } else { - button( + keyboard::button( text(tw( locale, "duplicates.dismissCheckedCount", @@ -64,12 +66,12 @@ pub fn view(state: &DuplicatesState, locale: UiLocale) -> Element<'_, Message> { .into(), ], vec![ - button(text(t(locale, "duplicates.checkAll")).size(13)) + keyboard::button(text(t(locale, "duplicates.checkAll")).size(13)) .on_press(Message::DuplicatesCheckAll) .padding([6, 12]) .style(inputs::secondary_button) .into(), - button(text(t(locale, "duplicates.uncheckAll")).size(13)) + keyboard::button(text(t(locale, "duplicates.uncheckAll")).size(13)) .on_press(Message::DuplicatesUncheckAll) .padding([6, 12]) .style(inputs::secondary_button) @@ -119,14 +121,17 @@ pub fn view(state: &DuplicatesState, locale: UiLocale) -> Element<'_, Message> { }; pairs = pairs.push(inputs::card( row![ - checkbox("", checked) - .on_toggle({ - let a = pair.post_id_a.clone(); - let b = pair.post_id_b.clone(); - move |_| Message::DuplicatesToggle(a.clone(), b.clone()) - }) - .size(16), - button( + keyboard::focusable( + checkbox("", checked) + .on_toggle({ + let a = pair.post_id_a.clone(); + let b = pair.post_id_b.clone(); + move |_| Message::DuplicatesToggle(a.clone(), b.clone()) + }) + .size(16), + true, + ), + keyboard::button( text(pair.title_a.clone()) .size(13) .shaping(Shaping::Advanced) @@ -135,7 +140,7 @@ pub fn view(state: &DuplicatesState, locale: UiLocale) -> Element<'_, Message> { .padding([5, 8]) .style(inputs::disclosure_button), text("→").size(14).color(inputs::LABEL_COLOR), - button( + keyboard::button( text(pair.title_b.clone()) .size(13) .shaping(Shaping::Advanced) @@ -149,7 +154,7 @@ pub fn view(state: &DuplicatesState, locale: UiLocale) -> Element<'_, Message> { } else { Color::from_rgb(0.55, 0.76, 0.92) }), - button(text(t(locale, "duplicates.dismiss")).size(12)) + keyboard::button(text(t(locale, "duplicates.dismiss")).size(12)) .on_press(Message::DuplicatesDismiss( pair.post_id_a.clone(), pair.post_id_b.clone() @@ -163,7 +168,7 @@ pub fn view(state: &DuplicatesState, locale: UiLocale) -> Element<'_, Message> { } if state.result.has_more { pairs = pairs.push( - button(text(t(locale, "duplicates.showMore")).size(13)) + keyboard::button(text(t(locale, "duplicates.showMore")).size(13)) .on_press(Message::DuplicatesShowMore) .padding([7, 16]) .style(inputs::secondary_button), diff --git a/crates/bds-ui/src/views/git.rs b/crates/bds-ui/src/views/git.rs index f4ee692..e9852f9 100644 --- a/crates/bds-ui/src/views/git.rs +++ b/crates/bds-ui/src/views/git.rs @@ -1,3 +1,4 @@ +use crate::components::keyboard; use std::sync::{Arc, Mutex}; use bds_core::engine::git::{ @@ -6,7 +7,7 @@ use bds_core::engine::git::{ }; use bds_core::i18n::UiLocale; use iced::widget::text::{Shaping, Wrapping}; -use iced::widget::{Space, button, column, container, row, scrollable, text, text_input, tooltip}; +use iced::widget::{Space, column, container, row, scrollable, text, text_input, tooltip}; use iced::{Alignment, Background, Color, Element, Font, Length}; use crate::app::Message; @@ -152,7 +153,7 @@ pub fn sidebar_view( text(t(locale, "git.notRepository")) .size(12) .color(Color::from_rgb(0.6, 0.6, 0.65)), - button(text(t(locale, "git.initialize")).size(12)) + keyboard::button(text(t(locale, "git.initialize")).size(12)) .on_press(Message::GitInitialize) .padding([5, 8]) .style(inputs::primary_button), @@ -180,7 +181,7 @@ pub fn sidebar_view( let network_running = state.network_run.is_some(); let network_button = |key: &'static str, icon: &'static str, message: Message| -> Element<'static, Message> { - let mut control = button(text(icon).size(16).shaping(Shaping::Advanced)) + let mut control = keyboard::button(text(icon).size(16).shaping(Shaping::Advanced)) .width(Length::Fixed(30.0)) .height(Length::Fixed(28.0)) .padding(0) @@ -189,13 +190,16 @@ pub fn sidebar_view( control = control.on_press(message); } - tooltip( - control, - text(t(locale, key)).size(12), - tooltip::Position::Bottom, + keyboard::focusable( + tooltip( + control, + text(t(locale, key)).size(12), + tooltip::Position::Bottom, + ) + .gap(4) + .style(inputs::tooltip_style), + !offline_mode && !network_running, ) - .gap(4) - .style(inputs::tooltip_style) .into() }; let actions = row![ @@ -240,7 +244,7 @@ pub fn sidebar_view( .padding([5, 7]) .style(inputs::field_style), { - let commit = button(text(t(locale, "git.commit")).size(11)) + let commit = keyboard::button(text(t(locale, "git.commit")).size(11)) .padding([5, 7]) .style(inputs::primary_button); if state.files.is_empty() || state.commit_message.trim().is_empty() { @@ -265,7 +269,7 @@ pub fn sidebar_view( content.extend(state.history.iter().take(20).map(history_button)); } content.push( - button(text(t(locale, "git.pruneLfs")).size(11)) + keyboard::button(text(t(locale, "git.pruneLfs")).size(11)) .on_press(Message::GitPruneLfs) .padding([4, 7]) .style(inputs::secondary_button) @@ -303,7 +307,7 @@ fn error_view(error: Option<&str>) -> Element<'static, Message> { fn status_button(file: &GitFileStatus) -> Element<'static, Message> { let path = file.path.clone(); - button( + keyboard::button( row![ text(path.clone()).size(11), Space::with_width(Length::Fill), @@ -322,7 +326,7 @@ fn history_button(commit: &GitCommit) -> Element<'static, Message> { let hash = commit.hash.clone(); let subject = commit.subject.clone().unwrap_or_else(|| hash.clone()); let short = hash.chars().take(7).collect::(); - button( + keyboard::button( column![ text(subject.clone()).size(11), row![ @@ -399,7 +403,7 @@ fn network_output(run: &GitNetworkRunState, locale: UiLocale) -> Element<'static .wrapping(Wrapping::Word) ) .padding(6), - button(text(t(locale, "common.cancel")).size(11)) + keyboard::button(text(t(locale, "common.cancel")).size(11)) .on_press(Message::CancelTask( crate::state::navigation::TaskSource::Local, run.task_id, @@ -445,7 +449,7 @@ pub fn diff_view( .iter() .map(|change| { let selected = state.selected_path.as_deref() == Some(&change.path); - let button = button(text(change.path.clone()).size(11)) + let button = keyboard::button(text(change.path.clone()).size(11)) .padding([4, 7]) .style(if selected { inputs::primary_button diff --git a/crates/bds-ui/src/views/import_editor.rs b/crates/bds-ui/src/views/import_editor.rs index 856a837..bff917a 100644 --- a/crates/bds-ui/src/views/import_editor.rs +++ b/crates/bds-ui/src/views/import_editor.rs @@ -1,3 +1,4 @@ +use crate::components::keyboard; use std::collections::HashSet; use bds_core::i18n::UiLocale; @@ -5,7 +6,7 @@ use bds_core::model::{ ImportCandidate, ImportDefinition, ImportExecutionResult, ImportItemKind, ImportItemStatus, ImportPhase, ImportProgress, ImportReport, ImportResolution, TaxonomyKind, }; -use iced::widget::{Space, button, column, container, progress_bar, row, scrollable, text}; +use iced::widget::{Space, column, container, progress_bar, row, scrollable, text}; use iced::{Alignment, Color, Element, Length}; use crate::app::Message; @@ -50,6 +51,25 @@ impl ImportEditorState { error: None, } } + + pub fn section_is_visible(&self, section: ImportSection) -> bool { + let Some(report) = &self.report else { + return false; + }; + match section { + ImportSection::Conflicts => report + .posts + .iter() + .chain(&report.pages) + .chain(&report.media) + .any(|item| item.status == ImportItemStatus::Conflict), + ImportSection::Posts => !report.posts.is_empty(), + ImportSection::Pages => !report.pages.is_empty(), + ImportSection::Media => !report.media.is_empty(), + ImportSection::Taxonomy => !report.taxonomies.is_empty(), + ImportSection::Macros => !report.macros.is_empty(), + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -62,6 +82,32 @@ pub enum ImportSection { Macros, } +impl ImportSection { + pub fn access_key(self) -> char { + match self { + Self::Conflicts => 'c', + Self::Posts => 'o', + Self::Pages => 'a', + Self::Media => 'm', + Self::Taxonomy => 't', + Self::Macros => 'r', + } + } + + pub fn from_access_key(key: char) -> Option { + [ + Self::Conflicts, + Self::Posts, + Self::Pages, + Self::Media, + Self::Taxonomy, + Self::Macros, + ] + .into_iter() + .find(|section| section.access_key() == key) + } +} + #[derive(Debug, Clone)] pub enum ImportEditorMsg { NameChanged(String), @@ -120,13 +166,13 @@ pub fn view<'a>(state: &'a ImportEditorState, locale: UiLocale) -> Element<'a, M column![ row![ name, - button(text(t(locale, "modal.confirmDelete.delete"))) + keyboard::button(text(t(locale, "modal.confirmDelete.delete"))) .on_press_maybe( (!busy).then_some(Message::ImportEditor(ImportEditorMsg::DeleteRequested,)) ) .padding([8, 12]) .style(inputs::danger_button), - button(text(t(locale, "import.analyze"))) + keyboard::button(text(t(locale, "import.analyze"))) .on_press_maybe( (!busy && state.definition.wxr_file_path.is_some()) .then_some(Message::ImportEditor(ImportEditorMsg::Analyze),) @@ -290,7 +336,7 @@ fn path_row<'a>( ] .spacing(5) .width(Length::Fill), - button(text(t(locale, "common.open"))) + keyboard::button(text(t(locale, "common.open"))) .on_press(Message::ImportEditor(action)) .padding([7, 12]) .style(inputs::secondary_button), @@ -457,7 +503,7 @@ fn execute_toolbar<'a>( .into(), ], vec![ - button(text(t(locale, "import.autoMap"))) + keyboard::button(text(t(locale, "import.autoMap"))) .on_press_maybe( (!state.is_analyzing && !state.is_executing) .then_some(Message::ImportEditor(ImportEditorMsg::AutoMapTaxonomy)), @@ -465,7 +511,7 @@ fn execute_toolbar<'a>( .padding([8, 12]) .style(inputs::secondary_button) .into(), - button(text(tw( + keyboard::button(text(tw( locale, "import.execute", &[("count", &count.to_string())], @@ -514,7 +560,7 @@ fn section<'a>( ) -> Vec> { let expanded = state.expanded.contains(§ion); let header = inputs::card( - button( + keyboard::button( row![ text(if expanded { "▾" } else { "▸" }).size(12), text(t(locale, title_key)).size(13), @@ -524,6 +570,7 @@ fn section<'a>( .on_press(Message::ImportEditor(ImportEditorMsg::ToggleSection( section, ))) + .hotkey(section.access_key()) .padding([6, 8]) .width(Length::Fill) .style(inputs::disclosure_button), @@ -673,7 +720,7 @@ fn taxonomy_rows<'a>( }), )) .width(Length::Fixed(260.0)), - button(text(t(locale, "common.clear"))) + keyboard::button(text(t(locale, "common.clear"))) .on_press_maybe(item.mapped_to.is_some().then_some(Message::ImportEditor( ImportEditorMsg::SetTaxonomyMapping { kind, diff --git a/crates/bds-ui/src/views/media_editor.rs b/crates/bds-ui/src/views/media_editor.rs index 4553b24..e066421 100644 --- a/crates/bds-ui/src/views/media_editor.rs +++ b/crates/bds-ui/src/views/media_editor.rs @@ -1,8 +1,9 @@ +use crate::components::keyboard; use std::collections::HashMap; use std::path::Path; use iced::widget::text::Shaping; -use iced::widget::{Space, button, column, container, image, row, scrollable, text}; +use iced::widget::{Space, column, container, image, row, scrollable, text}; use iced::{Color, Element, Length}; use bds_core::i18n::{self, UiLocale}; @@ -271,7 +272,7 @@ pub fn view<'a>( .style(status_bar::dropdown_bg) .into(); let quick_actions_button: Element<'a, Message> = - button(text(t(locale, "editor.quickActions")).size(13)) + keyboard::button(text(t(locale, "editor.quickActions")).size(13)) .on_press_maybe( ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::ToggleQuickActions)), ) @@ -290,17 +291,17 @@ pub fn view<'a>( vec![text(state.original_name.clone()).size(18).into()], vec![ quick_actions, - button(text(t(locale, "editor.replaceFile")).size(13)) + keyboard::button(text(t(locale, "editor.replaceFile")).size(13)) .on_press(Message::MediaEditor(MediaEditorMsg::ReplaceFile)) .style(inputs::secondary_button) .padding([6, 16]) .into(), - button(text(t(locale, "common.save")).size(13)) + keyboard::button(text(t(locale, "common.save")).size(13)) .on_press(Message::MediaEditor(MediaEditorMsg::Save)) .style(inputs::primary_button) .padding([6, 16]) .into(), - button(text(t(locale, "modal.confirmDelete.delete")).size(13)) + keyboard::button(text(t(locale, "modal.confirmDelete.delete")).size(13)) .on_press(Message::MediaEditor(MediaEditorMsg::Delete)) .style(inputs::danger_button) .padding([6, 16]) @@ -324,7 +325,7 @@ pub fn view<'a>( } else { Color::from_rgb(0.55, 0.58, 0.65) }; - button(text(label).size(12).shaping(Shaping::Advanced).color(color)) + keyboard::button(text(label).size(12).shaping(Shaping::Advanced).color(color)) .on_press(Message::MediaEditor(MediaEditorMsg::SwitchLanguage( flag.language.clone(), ))) @@ -430,7 +431,7 @@ pub fn view<'a>( .size(12) .color(Color::from_rgb(0.55, 0.58, 0.65)), Space::with_width(Length::Fill), - button(text(t(locale, "editor.linkToPost")).size(12)) + keyboard::button(text(t(locale, "editor.linkToPost")).size(12)) .on_press(Message::MediaEditor(MediaEditorMsg::TogglePostPicker)) .style(inputs::secondary_button) .padding([4, 10]), @@ -457,7 +458,7 @@ pub fn view<'a>( .post_picker_results .iter() .map(|post| { - button(text(post.title.clone()).size(12)) + keyboard::button(text(post.title.clone()).size(12)) .on_press(Message::MediaEditor(MediaEditorMsg::LinkPost( post.post_id.clone(), ))) @@ -497,13 +498,13 @@ pub fn view<'a>( .iter() .map(|post| { row![ - button(text(post.title.clone()).size(12)) + keyboard::button(text(post.title.clone()).size(12)) .on_press(Message::MediaEditor(MediaEditorMsg::OpenLinkedPost( post.post_id.clone() ))) .padding([4, 0]), Space::with_width(Length::Fill), - button(text(t(locale, "editor.unlinkMedia")).size(11)) + keyboard::button(text(t(locale, "editor.unlinkMedia")).size(11)) .on_press(Message::MediaEditor(MediaEditorMsg::UnlinkPost( post.post_id.clone() ))) @@ -569,7 +570,7 @@ fn quick_action_item<'a>( msg: MediaEditorMsg, enabled: bool, ) -> Element<'a, Message> { - button(text(label).size(12).shaping(Shaping::Advanced)) + keyboard::button(text(label).size(12).shaping(Shaping::Advanced)) .on_press_maybe(enabled.then_some(Message::MediaEditor(msg))) .padding([6, 12]) .style(status_bar::dropdown_item) diff --git a/crates/bds-ui/src/views/menu_editor.rs b/crates/bds-ui/src/views/menu_editor.rs index 6a075f1..5ed05dc 100644 --- a/crates/bds-ui/src/views/menu_editor.rs +++ b/crates/bds-ui/src/views/menu_editor.rs @@ -1,10 +1,12 @@ +use crate::components::keyboard; +use iced::widget::button; use std::collections::HashSet; use std::time::{Duration, Instant}; use bds_core::engine::menu::{MenuItem, MenuItemKind}; use bds_core::i18n::UiLocale; use iced::widget::{ - Space, button, column, container, mouse_area, row, scrollable, svg, text, text_input, tooltip, + Space, column, container, mouse_area, row, scrollable, svg, text, text_input, tooltip, }; use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Point, Theme}; use uuid::Uuid; @@ -810,19 +812,22 @@ fn toolbar_icon( } else { secondary_button }; - let mut control = button(text(glyph).size(18)) + let mut control = keyboard::button(text(glyph).size(18)) .width(Length::Fixed(38.0)) .height(Length::Fixed(34.0)) .style(style); if enabled { control = control.on_press(Message::MenuEditor(message)); } - tooltip( - control, - text(t(locale, key)).size(12), - tooltip::Position::Bottom, + keyboard::focusable( + tooltip( + control, + text(t(locale, key)).size(12), + tooltip::Position::Bottom, + ) + .gap(4), + enabled, ) - .gap(4) .into() } @@ -856,7 +861,7 @@ fn tree_item<'a>( let collapsed = state.collapsed.contains(&item.id); let id = item.id.clone(); let toggle: Element<'_, Message> = if is_submenu { - button(text(if collapsed { "▸" } else { "▾" }).size(14)) + keyboard::button(text(if collapsed { "▸" } else { "▾" }).size(14)) .on_press(Message::MenuEditor(MenuEditorMsg::ToggleExpanded( id.clone(), ))) @@ -884,7 +889,7 @@ fn tree_item<'a>( } else { item.label.clone() }; - let label_button = button( + let label_button = keyboard::button( row![ kind_icon(&item.kind), text(label).size(14), @@ -1016,7 +1021,7 @@ fn draft_editor<'a>( .filter(|page| page_matches_query(page, &draft.query)) { choices = choices.push( - button(text(page.title.clone())) + keyboard::button(text(page.title.clone())) .on_press(Message::MenuEditor(MenuEditorMsg::ChoosePage( page.id.clone(), ))) @@ -1032,7 +1037,7 @@ fn draft_editor<'a>( .filter(|name| category_matches_query(name, &draft.query)) { choices = choices.push( - button(text(category.clone())) + keyboard::button(text(category.clone())) .on_press(Message::MenuEditor(MenuEditorMsg::ChooseCategory( category.clone(), ))) @@ -1047,10 +1052,10 @@ fn draft_editor<'a>( DraftKind::Category => "menuEditor.useCategory", }; let actions = row![ - button(text(t(locale, submit_label))) + keyboard::button(text(t(locale, submit_label))) .on_press(Message::MenuEditor(MenuEditorMsg::SubmitDraft)) .style(primary_button), - button(text(t(locale, "common.cancel"))) + keyboard::button(text(t(locale, "common.cancel"))) .on_press(Message::MenuEditor(MenuEditorMsg::CancelDraft)) .style(secondary_button), ] diff --git a/crates/bds-ui/src/views/metadata_diff.rs b/crates/bds-ui/src/views/metadata_diff.rs index 0857113..afc6554 100644 --- a/crates/bds-ui/src/views/metadata_diff.rs +++ b/crates/bds-ui/src/views/metadata_diff.rs @@ -1,5 +1,6 @@ +use crate::components::keyboard; use iced::widget::text::Shaping; -use iced::widget::{Space, button, column, container, row, scrollable, text}; +use iced::widget::{Space, column, container, row, scrollable, text}; use iced::{Color, Element, Length}; use bds_core::engine::metadata_diff::{DiffReport, RepairDirection}; @@ -18,10 +19,11 @@ pub struct MetadataDiffState { } pub fn view<'a>(state: &'a MetadataDiffState, locale: UiLocale) -> Element<'a, Message> { - let run = button(text(t(locale, "metadataDiff.run")).size(13)) + let run = keyboard::button(text(t(locale, "metadataDiff.run")).size(13)) .on_press_maybe( (!state.is_running && !state.is_repairing).then_some(Message::RunMetadataDiff), ) + .hotkey('r') .style(inputs::primary_button) .padding([6, 16]); let mut content = column![ @@ -65,7 +67,7 @@ pub fn view<'a>(state: &'a MetadataDiffState, locale: UiLocale) -> Element<'a, M ) }); let actions = row![ - button(text(t(locale, "metadataDiff.fileToDb")).size(12)) + keyboard::button(text(t(locale, "metadataDiff.fileToDb")).size(12)) .on_press_maybe((!state.is_repairing).then_some( Message::RepairMetadataDiffItem { index, @@ -74,7 +76,7 @@ pub fn view<'a>(state: &'a MetadataDiffState, locale: UiLocale) -> Element<'a, M )) .style(inputs::secondary_button) .padding([5, 10]), - button(text(t(locale, "metadataDiff.dbToFile")).size(12)) + keyboard::button(text(t(locale, "metadataDiff.dbToFile")).size(12)) .on_press_maybe((!state.is_repairing).then_some( Message::RepairMetadataDiffItem { index, @@ -116,7 +118,7 @@ pub fn view<'a>(state: &'a MetadataDiffState, locale: UiLocale) -> Element<'a, M .align_y(iced::Alignment::Center); if orphan.reason == "file_without_db_entry" { item = item.push( - button(text(t(locale, "metadataDiff.importOrphan")).size(12)) + keyboard::button(text(t(locale, "metadataDiff.importOrphan")).size(12)) .on_press_maybe( (!state.is_repairing) .then_some(Message::ImportMetadataOrphan(index)), diff --git a/crates/bds-ui/src/views/modal.rs b/crates/bds-ui/src/views/modal.rs index d6cdbee..ed52465 100644 --- a/crates/bds-ui/src/views/modal.rs +++ b/crates/bds-ui/src/views/modal.rs @@ -1,9 +1,9 @@ +use crate::components::keyboard; +use iced::widget::button; use std::path::Path; use iced::widget::text::Shaping; -use iced::widget::{ - Space, button, checkbox, column, container, image, row, scrollable, text, text_input, -}; +use iced::widget::{Space, checkbox, column, container, image, row, scrollable, text, text_input}; use iced::{Alignment, Background, Border, Color, Element, Length, Shadow, Theme, Vector}; use bds_core::i18n::UiLocale; @@ -282,7 +282,7 @@ pub fn view( let selected = selected_project_id.as_deref() == Some(project.id.as_str()); let marker = if selected { "●" } else { "○" }; project_rows = project_rows.push( - button( + keyboard::button( row![ text(marker).size(12), text(project.name.clone()).size(13), @@ -307,12 +307,12 @@ pub fn view( content = content.push(project_rows); } - let cancel = button(text(t(locale, "remoteConnection.cancel")).size(13)) + let cancel = keyboard::button(text(t(locale, "remoteConnection.cancel")).size(13)) .on_press(Message::DismissModal) .padding([6, 16]) .style(cancel_button_style); let action = if !connected { - let button = button( + let button = keyboard::button( text(if connecting { t(locale, "remoteConnection.connecting") } else { @@ -328,7 +328,7 @@ pub fn view( button.on_press(Message::RemoteConnectRequested) } } else { - let button = button(text(t(locale, "remoteConnection.open")).size(13)) + let button = keyboard::button(text(t(locale, "remoteConnection.open")).size(13)) .padding([6, 16]) .style(confirm_button_style); if selected_project_id.is_some() { @@ -393,7 +393,7 @@ pub fn view( let on_confirm_clone = on_confirm.clone(); let buttons = row![ - button( + keyboard::button( text(t(locale, "modal.confirmDelete.cancel")) .size(13) .shaping(Shaping::Advanced) @@ -402,7 +402,7 @@ pub fn view( .padding([6, 16]) .style(cancel_button_style), Space::with_width(Length::Fill), - button( + keyboard::button( text(t(locale, "modal.confirmDelete.delete")) .size(13) .shaping(Shaping::Advanced) @@ -438,7 +438,7 @@ pub fn view( let on_confirm_clone = on_confirm.clone(); let buttons = row![ - button( + keyboard::button( text(t(locale, "modal.confirm.cancel")) .size(13) .shaping(Shaping::Advanced) @@ -447,7 +447,7 @@ pub fn view( .padding([6, 16]) .style(cancel_button_style), Space::with_width(Length::Fill), - button( + keyboard::button( text(t(locale, "modal.confirm.confirm")) .size(13) .shaping(Shaping::Advanced) @@ -474,12 +474,12 @@ pub fn view( ModalState::SearchIndexRepair => { let buttons = row![ - button(text(t(locale, "searchIndexRepair.later")).size(13)) + keyboard::button(text(t(locale, "searchIndexRepair.later")).size(13)) .on_press(Message::DismissModal) .padding([6, 16]) .style(cancel_button_style), Space::with_width(Length::Fill), - button(text(t(locale, "searchIndexRepair.rebuildNow")).size(13)) + keyboard::button(text(t(locale, "searchIndexRepair.rebuildNow")).size(13)) .on_press(Message::ConfirmModal(ConfirmAction::RebuildSearchIndex)) .padding([6, 16]) .style(confirm_button_style), @@ -520,7 +520,7 @@ pub fn view( ); } let mut actions = row![ - button(text(t(locale, "find.next"))) + keyboard::button(text(t(locale, "find.next"))) .on_press(Message::FindNext) .style(inputs::primary_button), ] @@ -528,12 +528,12 @@ pub fn view( if show_replace { actions = actions .push( - button(text(t(locale, "find.replace"))) + keyboard::button(text(t(locale, "find.replace"))) .on_press(Message::ReplaceCurrent) .style(inputs::secondary_button), ) .push( - button(text(t(locale, "find.replaceAll"))) + keyboard::button(text(t(locale, "find.replaceAll"))) .on_press(Message::ReplaceAll) .style(inputs::secondary_button), ); @@ -582,7 +582,7 @@ pub fn view( } else { content.push(Space::with_height(16.0)).push(row![ Space::with_width(Length::Fill), - button(text(t(locale, "tasks.cancelTask")).size(13)) + keyboard::button(text(t(locale, "tasks.cancelTask")).size(13)) .on_press(Message::CancelTask( crate::state::navigation::TaskSource::Local, task_id, @@ -607,7 +607,7 @@ pub fn view( external_url, external_text, } => { - let internal_tab = button( + let internal_tab = keyboard::button( text(t(locale, "modal.postInsertLink.tabInternal")) .size(13) .shaping(Shaping::Advanced) @@ -623,7 +623,7 @@ pub fn view( .padding([8, 16]) .style(cancel_button_style); - let external_tab = button( + let external_tab = keyboard::button( text(t(locale, "modal.postInsertLink.tabExternal")) .size(13) .shaping(Shaping::Advanced) @@ -666,7 +666,7 @@ pub fn view( let mut column = column![search_input, Space::with_height(12.0)]; for link in results { column = column.push( - button( + keyboard::button( row![ column![ text(link.title.clone()) @@ -749,7 +749,7 @@ pub fn view( Space::with_height(12.0), row![ Space::with_width(Length::Fill), - button(text(t(locale, "modal.postInsertLink.insert"))) + keyboard::button(text(t(locale, "modal.postInsertLink.insert"))) .on_press(Message::PostEditor( PostEditorMsg::PostInsertLinkExternalInsert )) @@ -760,7 +760,7 @@ pub fn view( .spacing(8) .into(); - let create_post_btn: Element<'static, Message> = button( + let create_post_btn: Element<'static, Message> = keyboard::button( text(t(locale, "modal.postInsertLink.createPost")) .size(12) .shaping(Shaping::Advanced) @@ -792,7 +792,7 @@ pub fn view( }; let buttons = row![ - button(cancel_text) + keyboard::button(cancel_text) .on_press(Message::DismissModal) .padding([6, 16]) .style(cancel_button_style), @@ -903,7 +903,7 @@ pub fn view( .spacing(4) .align_x(Alignment::Center); - let btn = button(media_col) + let btn = keyboard::button(media_col) .on_press(Message::PostEditor(PostEditorMsg::PostInsertMediaSelected( m.id.clone(), ))) @@ -963,7 +963,7 @@ pub fn view( .shaping(Shaping::Advanced); let buttons = row![ - button(cancel_text) + keyboard::button(cancel_text) .on_press(Message::DismissModal) .padding([6, 16]) .style(cancel_button_style), @@ -1045,7 +1045,7 @@ pub fn view( .spacing(4) .align_x(Alignment::Center); - let btn = button(media_col) + let btn = keyboard::button(media_col) .on_press(Message::PostEditor( PostEditorMsg::PostGalleryImageSelected(index), )) @@ -1082,7 +1082,7 @@ pub fn view( .size(13) .shaping(Shaping::Advanced); - let close_button = button(close_text) + let close_button = keyboard::button(close_text) .on_press(Message::DismissModal) .padding([6, 16]) .style(cancel_button_style); @@ -1106,7 +1106,7 @@ pub fn view( .width(Length::Fill) .center_x(Length::Fill), row![ - button(text("<")) + keyboard::button(text("<")) .on_press(Message::PostEditor(PostEditorMsg::PostGalleryPrevious)) .padding([6, 12]) .style(cancel_button_style), @@ -1115,12 +1115,12 @@ pub fn view( .size(12) .shaping(Shaping::Advanced), Space::with_width(Length::Fill), - button(text(">")) + keyboard::button(text(">")) .on_press(Message::PostEditor(PostEditorMsg::PostGalleryNext)) .padding([6, 12]) .style(cancel_button_style), Space::with_width(12.0), - button(text(t(locale, "modal.postGallery.backToGrid"))) + keyboard::button(text(t(locale, "modal.postGallery.backToGrid"))) .on_press(Message::PostEditor( PostEditorMsg::PostGalleryCloseLightbox )) @@ -1166,13 +1166,15 @@ pub fn view( .iter() .enumerate() .map(|(index, field)| { - let toggle = + let toggle = keyboard::focusable( checkbox(field.label.clone(), field.accepted) .on_toggle_maybe((!field.locked).then_some(move |value| { Message::ToggleAiSuggestionField(index, value) })) .size(16) - .text_size(13); + .text_size(13), + !field.locked, + ); container( column![ toggle, @@ -1211,12 +1213,12 @@ pub fn view( .collect::>>(); let buttons = row![ - button(text(t(locale, "common.cancel")).size(13)) + keyboard::button(text(t(locale, "common.cancel")).size(13)) .on_press(Message::DismissModal) .padding([6, 16]) .style(cancel_button_style), Space::with_width(Length::Fill), - button(text(t(locale, "modal.aiSuggestions.applySelected")).size(13)) + keyboard::button(text(t(locale, "modal.aiSuggestions.applySelected")).size(13)) .on_press(Message::ApplyAiSuggestions(target, fields)) .padding([6, 16]) .style(confirm_button_style), @@ -1275,7 +1277,7 @@ pub fn view( ), }; let status = language.existing_status.clone().unwrap_or_default(); - button( + keyboard::button( row![ text(format!("{} {}", language.flag_emoji, language.name)) .size(13) @@ -1302,7 +1304,7 @@ pub fn view( Space::with_height(12.0), column(rows).spacing(6), Space::with_height(16.0), - button(text(t(locale, "common.cancel")).size(13)) + keyboard::button(text(t(locale, "common.cancel")).size(13)) .on_press(Message::DismissModal) .padding([6, 16]) .style(cancel_button_style), diff --git a/crates/bds-ui/src/views/panel.rs b/crates/bds-ui/src/views/panel.rs index 18c7dcd..c2f663d 100644 --- a/crates/bds-ui/src/views/panel.rs +++ b/crates/bds-ui/src/views/panel.rs @@ -1,5 +1,7 @@ +use crate::components::keyboard; +use iced::widget::button; use iced::widget::text::Shaping; -use iced::widget::{Space, button, column, container, progress_bar, row, scrollable, text}; +use iced::widget::{Space, column, container, progress_bar, row, scrollable, text}; use iced::{Alignment, Background, Border, Color, Element, Font, Length, Theme}; use bds_core::engine::git::GitCommit; @@ -80,7 +82,7 @@ fn task_row( rows.push( row![ Space::with_width(Length::Fill), - button(text(t(locale, "tasks.cancelTask")).size(10)) + keyboard::button(text(t(locale, "tasks.cancelTask")).size(10)) .on_press(Message::CancelTask(snapshot.source, snapshot.id)) .padding([3, 8]) .style(inputs::secondary_button), @@ -267,7 +269,7 @@ pub fn view( // Tab header — per layout.allium: tasks, output, post_links (only when // active editor tab is a post), git_log (only when active tab is post or // media). - let tasks_btn = button( + let tasks_btn = keyboard::button( text(t(locale, "common.tasks")) .size(12) .shaping(Shaping::Advanced), @@ -280,7 +282,7 @@ pub fn view( tab_inactive }); - let output_btn = button( + let output_btn = keyboard::button( text(t(locale, "panel.output")) .size(12) .shaping(Shaping::Advanced), @@ -293,7 +295,7 @@ pub fn view( tab_inactive }); - let close_btn = button(text("\u{2715}").size(12).shaping(Shaping::Advanced)) + let close_btn = keyboard::button(text("\u{2715}").size(12).shaping(Shaping::Advanced)) .on_press(Message::TogglePanel) .padding([4, 6]) .style(close_btn_style); @@ -301,7 +303,7 @@ pub fn view( let mut tab_row: Vec> = vec![tasks_btn.into(), output_btn.into()]; if active_tab_is_post { - let post_links_btn = button( + let post_links_btn = keyboard::button( text(t(locale, "panel.postLinks")) .size(12) .shaping(Shaping::Advanced), @@ -317,7 +319,7 @@ pub fn view( } if active_tab_is_post_or_media { - let git_log_btn = button( + let git_log_btn = keyboard::button( text(t(locale, "panel.gitLog")) .size(12) .shaping(Shaping::Advanced), @@ -372,7 +374,7 @@ pub fn view( let collapsed = collapsed_task_groups.contains(group_id); let group_name = snapshot.group_name.as_deref().unwrap_or(group_id); items.push( - button( + keyboard::button( row![ text(if collapsed { "\u{25b8}" } else { "\u{25be}" }).size(11), text(format!("{} ({})", group_name, members.len())) @@ -524,7 +526,7 @@ pub fn view( let hash = commit.hash.clone(); let subject = commit.subject.clone().unwrap_or_else(|| hash.clone()); let short = hash.chars().take(7).collect::(); - button( + keyboard::button( row![ text(short).size(11).font(iced::Font::MONOSPACE), text(subject.clone()).size(11), @@ -560,7 +562,7 @@ pub fn view( } fn post_link_button(locale: UiLocale, link: &ResolvedPostLink) -> Element<'static, Message> { - button(text(link.title.clone()).size(11).shaping(Shaping::Advanced)) + keyboard::button(text(link.title.clone()).size(11).shaping(Shaping::Advanced)) .on_press(Message::OpenTab(Tab { id: link.post_id.clone(), title: if link.title.is_empty() { diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index 5f88066..381adfc 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -1,10 +1,12 @@ +use crate::components::keyboard; +use iced::widget::button; use std::cell::RefCell; use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use iced::widget::text::{Shaping, Wrapping}; -use iced::widget::{Column, Space, button, column, container, row, scrollable, text, text_input}; +use iced::widget::{Column, Space, column, container, row, scrollable, text, text_input}; use iced::{Color, Element, Length, Theme}; use bds_core::i18n::{self, UiLocale}; @@ -489,7 +491,7 @@ pub fn view<'a>( format!("{}{}", truncate_header_title(&state.title), dirty_indicator) }; - let quick_actions_button: Element<'a, Message> = button( + let quick_actions_button: Element<'a, Message> = keyboard::button( text(t(locale, "editor.quickActions")) .size(13) .shaping(Shaping::Advanced), @@ -569,7 +571,7 @@ pub fn view<'a>( let mut header_action_items: Vec> = vec![ status_badge(locale, &state.status), quick_actions, - button( + keyboard::button( text(t(locale, "common.save")) .size(13) .shaping(Shaping::Advanced), @@ -581,7 +583,7 @@ pub fn view<'a>( ]; if state.status == PostStatus::Draft { header_action_items.push( - button( + keyboard::button( text(t(locale, "editor.publish")) .size(13) .shaping(Shaping::Advanced), @@ -594,7 +596,7 @@ pub fn view<'a>( } if !on_translation && state.status == PostStatus::Draft && state.published_at.is_some() { header_action_items.push( - button( + keyboard::button( text(t(locale, "editor.discard")) .size(13) .shaping(Shaping::Advanced), @@ -606,7 +608,7 @@ pub fn view<'a>( ); } header_action_items.push( - button( + keyboard::button( text(t(locale, "modal.confirmDelete.delete")) .size(13) .shaping(Shaping::Advanced), @@ -643,13 +645,14 @@ pub fn view<'a>( } else { format!("\u{25B6} {}", t(locale, "editor.metadata")) }; - let meta_toggle = button( + let meta_toggle = keyboard::button( text(meta_toggle_label) .size(12) .color(inputs::SECTION_COLOR) .shaping(Shaping::Advanced), ) .on_press(Message::PostEditor(PostEditorMsg::ToggleMetadata)) + .hotkey('m') .padding([8, 10]) .width(Length::Fill) .style(inputs::disclosure_button); @@ -663,7 +666,7 @@ pub fn view<'a>( for flag in &flags { let lang = flag.language.clone(); let label = flag.flag_emoji.to_string(); - let btn = button(text(label).size(14).shaping(Shaping::Advanced)) + let btn = keyboard::button(text(label).size(14).shaping(Shaping::Advanced)) .on_press(Message::PostEditor(PostEditorMsg::SwitchLanguage(lang))) .padding([2, 4]) .style(if flag.is_active { @@ -756,7 +759,7 @@ pub fn view<'a>( .align_y(iced::Alignment::Center); for tag in semantic_suggestions { chips = chips.push( - button(text(format!("+ {tag}")).size(11)) + keyboard::button(text(format!("+ {tag}")).size(11)) .on_press(Message::PostEditor(PostEditorMsg::AddSuggestedTag( tag.to_string(), ))) @@ -784,7 +787,7 @@ pub fn view<'a>( .align_y(iced::Alignment::Center); for tag in matching_suggestions { chips = chips.push( - button(text(tag).size(11)) + keyboard::button(text(tag).size(11)) .on_press(Message::PostEditor(PostEditorMsg::AddSuggestedTag( tag.to_string(), ))) @@ -795,10 +798,12 @@ pub fn view<'a>( if query_addable { let query = state.tags_input.trim().to_string(); chips = chips.push( - button(text(tw(locale, "editor.createTag", &[("name", &query)])).size(11)) - .on_press(Message::PostEditor(PostEditorMsg::AddSuggestedTag(query))) - .padding([4, 8]) - .style(inputs::secondary_button), + keyboard::button( + text(tw(locale, "editor.createTag", &[("name", &query)])).size(11), + ) + .on_press(Message::PostEditor(PostEditorMsg::AddSuggestedTag(query))) + .padding([4, 8]) + .style(inputs::secondary_button), ); } chips.wrap().into() @@ -860,7 +865,7 @@ pub fn view<'a>( Column::with_children(items).spacing(2).into() }; - let link_existing_button: Element<'a, Message> = button( + let link_existing_button: Element<'a, Message> = keyboard::button( text(t(locale, "editor.linkExistingMedia")) .size(11) .shaping(Shaping::Advanced), @@ -916,14 +921,14 @@ pub fn view<'a>( ] .spacing(2) .width(Length::Fill), - button( + keyboard::button( text(t(locale, "common.open")) .size(11) .shaping(Shaping::Advanced) ) .on_press(Message::PostEditor(PostEditorMsg::OpenLinkedMedia(open_id))) .padding([4, 10]), - button( + keyboard::button( text(t(locale, "editor.unlinkMedia")) .size(11) .shaping(Shaping::Advanced) @@ -981,13 +986,14 @@ pub fn view<'a>( } else { format!("\u{25B6} {}", t(locale, "editor.excerpt")) }; - let excerpt_toggle = button( + let excerpt_toggle = keyboard::button( text(excerpt_toggle_label) .size(12) .color(inputs::SECTION_COLOR) .shaping(Shaping::Advanced), ) .on_press(Message::PostEditor(PostEditorMsg::ToggleExcerpt)) + .hotkey('x') .padding([8, 10]) .width(Length::Fill) .style(inputs::disclosure_button); @@ -1036,7 +1042,7 @@ pub fn view<'a>( ], vec![ if show_content_actions { - button( + keyboard::button( text(t(locale, "editor.insertLink")) .size(13) .shaping(Shaping::Advanced), @@ -1049,7 +1055,7 @@ pub fn view<'a>( Space::new(0, 0).into() }, if show_content_actions { - button( + keyboard::button( text(t(locale, "editor.insertMedia")) .size(13) .shaping(Shaping::Advanced), @@ -1062,7 +1068,7 @@ pub fn view<'a>( Space::new(0, 0).into() }, if show_content_actions { - button( + keyboard::button( text(t(locale, "editor.gallery")) .size(13) .shaping(Shaping::Advanced), @@ -1209,7 +1215,7 @@ fn chip_input_field<'a>( .map(|chip| { let label = format!("{} \u{2715}", chip); let chip_val = chip.clone(); - button(text(label).size(11).shaping(Shaping::Advanced)) + keyboard::button(text(label).size(11).shaping(Shaping::Advanced)) .on_press(on_remove(chip_val)) .padding([2, 6]) .style(chip_button_style) @@ -1266,7 +1272,7 @@ fn mode_button<'a>( "preview" => t(locale, "editor.modePreview"), _ => t(locale, "editor.modeMarkdown"), }; - button(text(label).size(12).shaping(Shaping::Advanced)) + keyboard::button(text(label).size(12).shaping(Shaping::Advanced)) .on_press(message) .padding([4, 10]) .style(if active_mode == mode { @@ -1284,7 +1290,7 @@ fn quick_action_item<'a>( enabled: bool, ) -> Element<'a, Message> { let _ = locale; - button(text(label).size(12).shaping(Shaping::Advanced)) + keyboard::button(text(label).size(12).shaping(Shaping::Advanced)) .on_press_maybe(enabled.then_some(Message::PostEditor(msg))) .padding([6, 12]) .style(status_bar::dropdown_item) diff --git a/crates/bds-ui/src/views/project_selector.rs b/crates/bds-ui/src/views/project_selector.rs index b59106f..22d47dc 100644 --- a/crates/bds-ui/src/views/project_selector.rs +++ b/crates/bds-ui/src/views/project_selector.rs @@ -1,5 +1,7 @@ +use crate::components::keyboard; +use iced::widget::button; use iced::widget::text::Shaping; -use iced::widget::{Column, Space, button, container, row, svg, text}; +use iced::widget::{Column, Space, container, row, svg, text}; use iced::{Background, Border, Color, Element, Length, Theme}; use bds_core::i18n::UiLocale; @@ -148,7 +150,7 @@ pub fn view( }; items.push( - button(label) + keyboard::button(label) .on_press(Message::SwitchProject(id)) .padding([4, 8]) .width(Length::Fill) @@ -170,7 +172,7 @@ pub fn view( // Open and create project actions items.push( - button( + keyboard::button( row![ text("↗") .size(14) @@ -190,7 +192,7 @@ pub fn view( ); items.push( - button( + keyboard::button( row![ text("+") .size(14) @@ -256,7 +258,7 @@ pub fn trigger_button(project_name: &str) -> Element<'static, Message> { .shaping(Shaping::Advanced) .color(Color::from_rgb(0.55, 0.55, 0.60)); - button( + keyboard::button( row![folder_icon, name, chevron] .spacing(4) .align_y(iced::Alignment::Center), diff --git a/crates/bds-ui/src/views/script_editor.rs b/crates/bds-ui/src/views/script_editor.rs index a051702..9206ecf 100644 --- a/crates/bds-ui/src/views/script_editor.rs +++ b/crates/bds-ui/src/views/script_editor.rs @@ -1,6 +1,7 @@ +use crate::components::keyboard; use std::cell::RefCell; -use iced::widget::{Space, button, column, container, row, scrollable, text}; +use iced::widget::{Space, column, container, row, scrollable, text}; use iced::{Color, Element, Length, Theme}; use bds_core::i18n::UiLocale; @@ -125,22 +126,22 @@ pub fn view<'a>(state: &'a ScriptEditorState, locale: UiLocale) -> Element<'a, M status_badge(&state.status), ], vec![ - button(text(t(locale, "common.save")).size(13)) + keyboard::button(text(t(locale, "common.save")).size(13)) .on_press(Message::ScriptEditor(ScriptEditorMsg::Save)) .style(inputs::primary_button) .padding([6, 16]) .into(), - button(text(t(locale, "editor.run")).size(13)) + keyboard::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)) + keyboard::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)) + keyboard::button(text(t(locale, "modal.confirmDelete.delete")).size(13)) .on_press(Message::ScriptEditor(ScriptEditorMsg::Delete)) .style(inputs::danger_button) .padding([6, 16]) diff --git a/crates/bds-ui/src/views/settings_view.rs b/crates/bds-ui/src/views/settings_view.rs index 71c3df6..76dcb3e 100644 --- a/crates/bds-ui/src/views/settings_view.rs +++ b/crates/bds-ui/src/views/settings_view.rs @@ -1,5 +1,6 @@ +use crate::components::keyboard; use iced::widget::text::Shaping; -use iced::widget::{button, column, container, row, scrollable, text, text_editor, text_input}; +use iced::widget::{column, container, row, scrollable, text, text_editor, text_input}; use iced::{Alignment, Color, Element, Length}; use std::collections::BTreeMap; @@ -49,6 +50,34 @@ pub enum SettingsSection { MCP, } +impl SettingsSection { + pub fn access_key(&self) -> char { + match self { + Self::Project => 'p', + Self::Editor => 'r', + Self::AI => 'a', + Self::Technology => 't', + Self::Publishing => 'u', + Self::Data => 'd', + Self::MCP => 'c', + } + } + + pub fn from_access_key(key: char) -> Option { + [ + Self::Project, + Self::Editor, + Self::AI, + Self::Technology, + Self::Publishing, + Self::Data, + Self::MCP, + ] + .into_iter() + .find(|section| section.access_key() == key) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SettingsCategoryRow { pub name: String, @@ -261,6 +290,13 @@ impl Default for SettingsViewState { } impl SettingsViewState { + pub fn section_is_visible(&self, section: &SettingsSection, locale: UiLocale) -> bool { + self.search_query.is_empty() + || t(locale, section.i18n_key()) + .to_lowercase() + .contains(&self.search_query.to_lowercase()) + } + pub fn focus_section(&mut self, section: SettingsSection) { self.collapsed = SettingsSection::all() .iter() @@ -369,12 +405,10 @@ pub fn view<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, M .padding([8, 10]) .style(inputs::field_style); - let query_lower = state.search_query.to_lowercase(); - let mut section_items = Vec::new(); for section in state.ordered_sections() { let label = t(locale, section.i18n_key()); - if !query_lower.is_empty() && !label.to_lowercase().contains(&query_lower) { + if !state.section_is_visible(§ion, locale) { continue; } let collapsed = state.collapsed.contains(§ion); @@ -387,7 +421,7 @@ pub fn view<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, M text(t(locale, "common.noResults")) .size(14) .color(Color::from_rgb(0.7, 0.72, 0.78)), - button(text(t(locale, "common.clear")).size(13)) + keyboard::button(text(t(locale, "common.clear")).size(13)) .on_press(Message::Settings(SettingsMsg::SearchChanged(String::new()))) .style(inputs::secondary_button) .padding([6, 12]), @@ -423,7 +457,7 @@ fn render_section<'a>( locale: UiLocale, ) -> Element<'a, Message> { let toggle_char = if collapsed { "\u{25B6}" } else { "\u{25BC}" }; - let header = button( + let header = keyboard::button( row![ text(toggle_char).size(12), text(label.to_string()).size(14).color(Color::WHITE), @@ -434,6 +468,7 @@ fn render_section<'a>( .on_press(Message::Settings(SettingsMsg::ToggleSection( section.clone(), ))) + .hotkey(section.access_key()) .padding([6, 8]) .width(Length::Fill) .style(inputs::disclosure_button); @@ -478,11 +513,11 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen inputs::labeled_input(&t(locale, "settings.dataPath"), "", &state.data_path, |s| { Message::Settings(SettingsMsg::DataPathChanged(s)) },), - button(text(t(locale, "settings.browse")).size(12)) + keyboard::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)) + keyboard::button(text(t(locale, "settings.reset")).size(12)) .on_press(Message::Settings(SettingsMsg::ResetDataPath)) .style(inputs::secondary_button) .padding([6, 12]), @@ -559,11 +594,11 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen |row| Message::Settings(SettingsMsg::BlogmarkCategoryChanged(row.name)), ); let copy_blogmark_bookmarklet = - button(text(t(locale, "settings.copyBlogmarkBookmarklet")).size(13)) + keyboard::button(text(t(locale, "settings.copyBlogmarkBookmarklet")).size(13)) .on_press(Message::Settings(SettingsMsg::CopyBlogmarkBookmarklet)) .style(inputs::secondary_button) .padding([6, 16]); - let save = button(text(t(locale, "common.save")).size(13)) + let save = keyboard::button(text(t(locale, "common.save")).size(13)) .on_press(Message::Settings(SettingsMsg::SaveProject)) .style(inputs::primary_button) .padding([6, 16]); @@ -619,7 +654,7 @@ fn section_editor<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element state.hide_unchanged_regions, |b| Message::Settings(SettingsMsg::HideUnchangedRegionsChanged(b)), ); - let save = button(text(t(locale, "common.save")).size(13)) + let save = keyboard::button(text(t(locale, "common.save")).size(13)) .on_press(Message::Settings(SettingsMsg::SaveEditor)) .style(inputs::primary_button) .padding([6, 16]); @@ -656,11 +691,11 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, ] .spacing(4); let btns = row![ - button(text(t(locale, "common.save")).size(13)) + keyboard::button(text(t(locale, "common.save")).size(13)) .on_press(Message::Settings(SettingsMsg::SaveAi)) .style(inputs::primary_button) .padding([6, 16]), - button(text(t(locale, "settings.resetToDefault")).size(13)) + keyboard::button(text(t(locale, "settings.resetToDefault")).size(13)) .on_press(Message::Settings(SettingsMsg::ResetSystemPrompt)) .style(inputs::secondary_button) .padding([6, 16]), @@ -727,7 +762,7 @@ fn ai_mode_block<'a>( Message::Settings(SettingsMsg::AiEndpointUrlChanged(kind, value)) } ), - button(text(t(locale, "settings.refreshModels")).size(13)) + keyboard::button(text(t(locale, "settings.refreshModels")).size(13)) .on_press(Message::Settings(SettingsMsg::RefreshAiModels(kind))) .style(inputs::secondary_button) .padding([6, 16]), @@ -772,7 +807,7 @@ fn ai_mode_block<'a>( state.image_supports_vision, move |value| Message::Settings(SettingsMsg::AiVisionChanged(kind, value)), ), - button(text(t(locale, "settings.testChat")).size(13)) + keyboard::button(text(t(locale, "settings.testChat")).size(13)) .on_press(Message::Settings(SettingsMsg::TestAi(kind))) .style(inputs::secondary_button) .padding([6, 16]), @@ -815,11 +850,11 @@ fn section_publishing<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Ele |s| Message::Settings(SettingsMsg::SshRemotePathChanged(s)), ); let btns = row![ - button(text(t(locale, "common.save")).size(13)) + keyboard::button(text(t(locale, "common.save")).size(13)) .on_press(Message::Settings(SettingsMsg::SavePublishing)) .style(inputs::primary_button) .padding([6, 16]), - button(text(t(locale, "settings.clear")).size(13)) + keyboard::button(text(t(locale, "settings.clear")).size(13)) .on_press(Message::Settings(SettingsMsg::ClearPublishing)) .style(inputs::danger_button) .padding([6, 16]), @@ -834,37 +869,37 @@ fn section_publishing<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Ele fn section_data<'a>(locale: UiLocale) -> Element<'a, Message> { let rebuild_btns = column![ - button(text(t(locale, "settings.rebuildPosts")).size(13)) + keyboard::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)) + keyboard::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)) + keyboard::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)) + keyboard::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)) + keyboard::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)) + keyboard::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)) + keyboard::button(text(t(locale, "settings.regenerateThumbnails")).size(13)) .on_press(Message::Settings(SettingsMsg::RegenerateThumbnails)) .style(inputs::secondary_button) .padding([6, 16]) @@ -872,12 +907,12 @@ fn section_data<'a>(locale: UiLocale) -> Element<'a, Message> { ] .spacing(4); - let open = button(text(t(locale, "settings.openDataFolder")).size(13)) + let open = keyboard::button(text(t(locale, "settings.openDataFolder")).size(13)) .on_press(Message::Settings(SettingsMsg::OpenDataFolder)) .style(inputs::secondary_button) .padding([6, 16]); - let install_cli = button(text(t(locale, "settings.installCli")).size(13)) + let install_cli = keyboard::button(text(t(locale, "settings.installCli")).size(13)) .on_press(Message::Settings(SettingsMsg::InstallCli)) .style(inputs::secondary_button) .padding([6, 16]); @@ -900,14 +935,17 @@ fn section_mcp<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a inputs::LABEL_COLOR }; let server = column![ - iced::widget::checkbox(t(locale, "settings.mcpEnable"), state.mcp_enabled) - .on_toggle(|value| Message::Settings(SettingsMsg::McpEnabledChanged(value))), + keyboard::focusable( + iced::widget::checkbox(t(locale, "settings.mcpEnable"), state.mcp_enabled) + .on_toggle(|value| Message::Settings(SettingsMsg::McpEnabledChanged(value))), + true, + ), row![ text(status).size(13).color(status_color), text(state.mcp_endpoint.clone()) .size(12) .color(inputs::LABEL_COLOR), - button(text(t(locale, "settings.mcpRefresh")).size(12)) + keyboard::button(text(t(locale, "settings.mcpRefresh")).size(12)) .on_press(Message::Settings(SettingsMsg::McpRefresh)) .style(inputs::secondary_button) .padding([5, 10]), @@ -940,13 +978,13 @@ fn section_mcp<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a ] .spacing(2) .width(Length::Fill), - button(text(t(locale, "settings.mcpApprove")).size(12)) + keyboard::button(text(t(locale, "settings.mcpApprove")).size(12)) .on_press(Message::Settings(SettingsMsg::McpProposalAccepted( proposal.id.clone() ))) .style(inputs::primary_button) .padding([5, 10]), - button(text(t(locale, "settings.mcpReject")).size(12)) + keyboard::button(text(t(locale, "settings.mcpReject")).size(12)) .on_press(Message::Settings(SettingsMsg::McpProposalRejected( proposal.id.clone() ))) @@ -964,10 +1002,13 @@ fn section_mcp<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a let agents = column(state.mcp_agents.iter().map(|agent| { let configured = agent.configured; column![ - iced::widget::checkbox(agent.label.clone(), configured).on_toggle({ - let agent = agent.agent; - move |_| Message::Settings(SettingsMsg::McpAgentToggled(agent)) - }), + keyboard::focusable( + iced::widget::checkbox(agent.label.clone(), configured).on_toggle({ + let agent = agent.agent; + move |_| Message::Settings(SettingsMsg::McpAgentToggled(agent)) + }), + true, + ), text(agent.config_path.clone()) .size(11) .color(inputs::LABEL_COLOR), diff --git a/crates/bds-ui/src/views/sidebar.rs b/crates/bds-ui/src/views/sidebar.rs index 8036bb6..ba32bae 100644 --- a/crates/bds-ui/src/views/sidebar.rs +++ b/crates/bds-ui/src/views/sidebar.rs @@ -1,7 +1,9 @@ +use crate::components::keyboard; +use iced::widget::button; use std::path::{Path, PathBuf}; use iced::widget::text::Shaping; -use iced::widget::{Space, button, column, container, image, row, scrollable, text, text_input}; +use iced::widget::{Space, column, container, image, row, scrollable, text, text_input}; use iced::{Background, Border, Color, Element, Length, Theme}; use bds_core::i18n::UiLocale; @@ -301,10 +303,10 @@ fn row_delete_style(_theme: &Theme, status: button::Status) -> button::Style { /// Per sidebar_views.allium *ListItemEntry RowLayout: right-aligned, visible /// only on row hover, routed to the row's delete message. fn with_row_delete( - open_button: iced::widget::Button<'static, Message>, + open_button: impl Into>, on_delete: Message, ) -> Element<'static, Message> { - let delete_button = button( + let delete_button = keyboard::button( text("\u{2715}") // ✕ .size(11) .shaping(Shaping::Advanced), @@ -313,7 +315,7 @@ fn with_row_delete( .padding([2, 6]) .style(row_delete_style); iced::widget::hover( - open_button, + open_button.into(), container(delete_button) .width(Length::Fill) .height(Length::Fill) @@ -362,7 +364,7 @@ fn calendar_widget( } else { calendar_style }; - let year_btn = button(text(label).size(11).shaping(Shaping::Advanced)) + let year_btn = keyboard::button(text(label).size(11).shaping(Shaping::Advanced)) .on_press(if year_selected { on_year_clone(None) } else { @@ -384,7 +386,7 @@ fn calendar_widget( } else { calendar_style }; - let month_btn = button(text(label).size(10).shaping(Shaping::Advanced)) + let month_btn = keyboard::button(text(label).size(10).shaping(Shaping::Advanced)) .on_press(if month_selected { on_month_clone(None) } else { @@ -428,7 +430,7 @@ fn chip_selector( } else { chip_style }; - let chip = button(text(tag.clone()).size(10).shaping(Shaping::Advanced)) + let chip = keyboard::button(text(tag.clone()).size(10).shaping(Shaping::Advanced)) .on_press(on_toggle_clone(tag_clone)) .padding([2, 6]) .style(style_fn); @@ -476,7 +478,7 @@ fn single_select_chip_selector( }; let value = value.clone(); let on_toggle = on_toggle.clone(); - button(text(display.clone()).size(10).shaping(Shaping::Advanced)) + keyboard::button(text(display.clone()).size(10).shaping(Shaping::Advanced)) .on_press(if is_selected { on_toggle(None) } else { @@ -599,7 +601,7 @@ fn post_filter_panel( // Clear all filters button if filter.has_active_filters() { sections.push( - button( + keyboard::button( text(t(locale, "sidebar.filter.clearAll")) .size(10) .shaping(Shaping::Advanced), @@ -645,7 +647,7 @@ fn media_filter_panel(filter: &MediaFilter, locale: UiLocale) -> Element<'static if filter.has_active_filters() { sections.push( - button( + keyboard::button( text(t(locale, "sidebar.filter.clearAll")) .size(10) .shaping(Shaping::Advanced), @@ -711,7 +713,7 @@ pub fn view( row![ header, Space::with_width(Length::Fill), - button( + keyboard::button( text(t(locale, "common.add")) .size(11) .shaping(Shaping::Advanced) @@ -752,10 +754,11 @@ pub fn view( } else { "\u{25BC}" // ▼ toggle icon }; - let filter_toggle = button(text(toggle_label).size(11).shaping(Shaping::Advanced)) - .on_press(Message::TogglePostFilterPanel) - .padding([4, 6]) - .style(toggle_style); + let filter_toggle = + keyboard::button(text(toggle_label).size(11).shaping(Shaping::Advanced)) + .on_press(Message::TogglePostFilterPanel) + .padding([4, 6]) + .style(toggle_style); top_items.push(row![search, filter_toggle].spacing(4).into()); @@ -817,7 +820,7 @@ pub fn view( } else { item_style }; - button( + keyboard::button( container(column![label_text, date_text].spacing(1)) .width(Length::Fill) .clip(true), @@ -901,10 +904,11 @@ pub fn view( } else { "\u{25BC}" // ▼ toggle icon }; - let filter_toggle = button(text(toggle_label).size(11).shaping(Shaping::Advanced)) - .on_press(Message::ToggleMediaFilterPanel) - .padding([4, 6]) - .style(toggle_style); + let filter_toggle = + keyboard::button(text(toggle_label).size(11).shaping(Shaping::Advanced)) + .on_press(Message::ToggleMediaFilterPanel) + .padding([4, 6]) + .style(toggle_style); top_items.push(row![search, filter_toggle].spacing(4).into()); @@ -995,7 +999,7 @@ pub fn view( .spacing(8) .align_y(iced::Alignment::Center); - button(container(content).width(Length::Fill).clip(true)) + keyboard::button(container(content).width(Length::Fill).clip(true)) .on_press(Message::OpenTab(Tab { id: m.id.clone(), tab_type: TabType::Media, @@ -1046,7 +1050,7 @@ pub fn view( } else { item_style }; - let open_button = button( + let open_button = keyboard::button( container(column![label_text, date_text].spacing(1)) .width(Length::Fill) .clip(true), @@ -1097,7 +1101,7 @@ pub fn view( } else { item_style }; - let open_button = button( + let open_button = keyboard::button( container(column![label_text, date_text].spacing(1)) .width(Length::Fill) .clip(true), @@ -1145,7 +1149,7 @@ pub fn view( .size(10) .shaping(Shaping::Advanced) .color(muted); - let open_button = button( + let open_button = keyboard::button( container( column![ text(definition_name).size(12).shaping(Shaping::Advanced), @@ -1197,7 +1201,7 @@ pub fn view( .size(10) .shaping(Shaping::Advanced) .color(muted); - let open_button = button( + let open_button = keyboard::button( container( column![ text(conversation.title.clone()) @@ -1257,7 +1261,7 @@ pub fn view( is_dirty: false, }) }; - button(container(label_text).width(Length::Fill)) + keyboard::button(container(label_text).width(Length::Fill)) .on_press(msg) .padding([5, 8]) .width(Length::Fill) @@ -1291,7 +1295,7 @@ pub fn view( .map(|(key, section)| { let label = t(locale, key); let label_text = text(label).size(12).shaping(Shaping::Advanced); - button(container(label_text).width(Length::Fill)) + keyboard::button(container(label_text).width(Length::Fill)) .on_press(Message::OpenTagsSection(*section)) .padding([5, 8]) .width(Length::Fill) diff --git a/crates/bds-ui/src/views/site_validation.rs b/crates/bds-ui/src/views/site_validation.rs index 799be12..b830abd 100644 --- a/crates/bds-ui/src/views/site_validation.rs +++ b/crates/bds-ui/src/views/site_validation.rs @@ -1,5 +1,6 @@ +use crate::components::keyboard; use iced::widget::text::Shaping; -use iced::widget::{button, column, container, row, scrollable, text}; +use iced::widget::{column, container, row, scrollable, text}; use iced::{Background, Color, Element, Length, Theme}; use bds_core::i18n::UiLocale; @@ -21,7 +22,7 @@ pub struct SiteValidationState { pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a, Message> { let run_button = if state.is_running { - button( + keyboard::button( text(t(locale, "siteValidation.running")) .size(13) .shaping(Shaping::Advanced), @@ -29,12 +30,13 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a, .style(inputs::primary_button) .padding([6, 16]) } else { - button( + keyboard::button( text(t(locale, "siteValidation.run")) .size(13) .shaping(Shaping::Advanced), ) .on_press(Message::RunSiteValidation) + .hotkey('r') .style(inputs::primary_button) .padding([6, 16]) }; @@ -42,7 +44,7 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a, || !state.extra_files.is_empty() || !state.stale_files.is_empty(); let apply_button = if state.is_applying { - button( + keyboard::button( text(t(locale, "siteValidation.applying")) .size(13) .shaping(Shaping::Advanced), @@ -50,16 +52,17 @@ pub fn view<'a>(state: &'a SiteValidationState, locale: UiLocale) -> Element<'a, .style(inputs::secondary_button) .padding([6, 16]) } else if !state.is_running && state.error_message.is_none() && has_issues { - button( + keyboard::button( text(t(locale, "siteValidation.apply")) .size(13) .shaping(Shaping::Advanced), ) .on_press(Message::ApplySiteValidation) + .hotkey('a') .style(inputs::primary_button) .padding([6, 16]) } else { - button( + keyboard::button( text(t(locale, "siteValidation.apply")) .size(13) .shaping(Shaping::Advanced), diff --git a/crates/bds-ui/src/views/status_bar.rs b/crates/bds-ui/src/views/status_bar.rs index 10d1b72..07176b0 100644 --- a/crates/bds-ui/src/views/status_bar.rs +++ b/crates/bds-ui/src/views/status_bar.rs @@ -1,5 +1,7 @@ +use crate::components::keyboard; +use iced::widget::button; use iced::widget::text::Shaping; -use iced::widget::{Space, button, container, row, text}; +use iced::widget::{Space, container, row, text}; use iced::{Alignment, Background, Border, Color, Element, Length, Theme}; use bds_core::engine::task::TaskStatus; @@ -235,7 +237,7 @@ pub fn view( ); // Airplane mode toggle — ✈ icon - let airplane_btn = button(text("\u{2708}").size(13).shaping(Shaping::Advanced)) + let airplane_btn = keyboard::button(text("\u{2708}").size(13).shaping(Shaping::Advanced)) .on_press(Message::SetOfflineMode(!offline_mode)) .padding([2, 4]) .style(if offline_mode { @@ -249,7 +251,7 @@ pub fn view( .size(14) .shaping(Shaping::Advanced); - let locale_trigger = button(trigger_flag) + let locale_trigger = keyboard::button(trigger_flag) .on_press(Message::ToggleLocaleDropdown) .padding([1, 4]) .style(dropdown_trigger); diff --git a/crates/bds-ui/src/views/style_view.rs b/crates/bds-ui/src/views/style_view.rs index d45ebe7..4c12590 100644 --- a/crates/bds-ui/src/views/style_view.rs +++ b/crates/bds-ui/src/views/style_view.rs @@ -1,5 +1,7 @@ +use crate::components::keyboard; +use iced::widget::button; use iced::widget::text::Shaping; -use iced::widget::{Column, Space, button, column, container, row, scrollable, text}; +use iced::widget::{Column, Space, column, container, row, scrollable, text}; use iced::{Background, Border, Color, Element, Length, Radians, Theme, gradient}; use bds_core::i18n::UiLocale; @@ -220,7 +222,7 @@ fn swatch(background: Background, width_portion: u16) -> Element<'static, Messag fn theme_button<'a>(theme: &StyleTheme, selected_theme: &str) -> Element<'a, Message> { let selected = theme.name == selected_theme; let theme_name = theme.name.to_string(); - button( + keyboard::button( column![ row![ swatch(theme_accent_background(theme), 2), @@ -318,13 +320,13 @@ pub fn view<'a>( .size(13) .shaping(Shaping::Advanced); let apply_button: Element<'a, Message> = if state.can_apply() { - button(apply_label) + keyboard::button(apply_label) .on_press(Message::Style(StyleMsg::Apply)) .padding([8, 16]) .style(inputs::primary_button) .into() } else { - button(apply_label) + keyboard::button(apply_label) .padding([8, 16]) .style(inputs::primary_button) .into() diff --git a/crates/bds-ui/src/views/tab_bar.rs b/crates/bds-ui/src/views/tab_bar.rs index 6ec9c06..1841840 100644 --- a/crates/bds-ui/src/views/tab_bar.rs +++ b/crates/bds-ui/src/views/tab_bar.rs @@ -1,7 +1,9 @@ +use crate::components::keyboard; +use iced::widget::button; use iced::widget::scrollable::Direction; use iced::widget::text::Shaping; use iced::widget::tooltip::Position; -use iced::widget::{Space, button, container, row, scrollable, text, tooltip}; +use iced::widget::{Space, container, row, scrollable, text, tooltip}; use iced::{Background, Border, Color, Element, Font, Length, Theme}; use bds_core::i18n::UiLocale; @@ -177,9 +179,20 @@ pub fn view(tabs: &[Tab], active_tab: Option<&str>, locale: UiLocale) -> Element .width(Length::Fill) .clip(true); + // Keep the tooltip around non-interactive title content so it does + // not hide the tab and close controls from widget operations. + let tooltip_text = build_tooltip_text(tab, locale); + let title_area = tooltip( + title_area, + text(tooltip_text).size(11).shaping(Shaping::Advanced), + Position::Bottom, + ) + .gap(4) + .style(inputs::tooltip_style); + let label = row![ title_area, - button(text("\u{2715}").size(10).shaping(Shaping::Advanced)) + keyboard::button(text("\u{2715}").size(10).shaping(Shaping::Advanced)) .on_press(Message::CloseTab(close_id)) .padding(2) .style(close_style), @@ -188,24 +201,12 @@ pub fn view(tabs: &[Tab], active_tab: Option<&str>, locale: UiLocale) -> Element .align_y(iced::Alignment::Center); // tabs.allium: tab_min_width=100, tab_max_width=160 - let tab_btn = button(label) + keyboard::button(label) .on_press(Message::SelectTab(tab_id)) .padding([6, 8]) .width(Length::Fixed(TAB_WIDTH)) - .style(if is_active { tab_active } else { tab_inactive }); - - // tabs.allium tooltip: title + "(Preview)" if transient + "* Modified" if dirty - let tooltip_text = build_tooltip_text(tab, locale); - let tip: Element<'static, Message> = tooltip( - tab_btn, - text(tooltip_text).size(11).shaping(Shaping::Advanced), - Position::Bottom, - ) - .gap(4) - .style(inputs::tooltip_style) - .into(); - - tip + .style(if is_active { tab_active } else { tab_inactive }) + .into() }) .collect(); diff --git a/crates/bds-ui/src/views/tags_view.rs b/crates/bds-ui/src/views/tags_view.rs index 12dc5d0..43ea225 100644 --- a/crates/bds-ui/src/views/tags_view.rs +++ b/crates/bds-ui/src/views/tags_view.rs @@ -1,7 +1,9 @@ +use crate::components::keyboard; +use iced::widget::button; use std::collections::HashMap; use iced::widget::{ - Space, button, checkbox, column, container, pick_list, row, scrollable, text, text_input, + Space, checkbox, column, container, pick_list, row, scrollable, text, text_input, }; use iced::{Alignment, Background, Color, Element, Length, Theme}; @@ -247,69 +249,79 @@ fn view_categories<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen })); let name = category.name.clone(); cells.push( - container( + container(keyboard::focusable( checkbox("", category.render_in_lists).on_toggle(move |value| { Message::Settings(SettingsMsg::CategoryRenderInListsChanged( name.clone(), value, )) }), - ) + true, + )) .width(Length::Fixed(TOGGLE_WIDTH)) .into(), ); let name = category.name.clone(); cells.push( - container(checkbox("", category.show_title).on_toggle(move |value| { - Message::Settings(SettingsMsg::CategoryShowTitleChanged(name.clone(), value)) - })) + container(keyboard::focusable( + checkbox("", category.show_title).on_toggle(move |value| { + Message::Settings(SettingsMsg::CategoryShowTitleChanged(name.clone(), value)) + }), + true, + )) .width(Length::Fixed(TOGGLE_WIDTH)) .into(), ); let name = category.name.clone(); cells.push( - pick_list( - template_options.clone(), - Some(category.post_template_slug.clone()), - move |value| { - Message::Settings(SettingsMsg::CategoryPostTemplateChanged( - name.clone(), - value, - )) - }, + keyboard::focusable( + pick_list( + template_options.clone(), + Some(category.post_template_slug.clone()), + move |value| { + Message::Settings(SettingsMsg::CategoryPostTemplateChanged( + name.clone(), + value, + )) + }, + ) + .padding([7, 9]) + .style(inputs::select_style) + .width(Length::Fixed(TEMPLATE_WIDTH)), + true, ) - .padding([7, 9]) - .style(inputs::select_style) - .width(Length::Fixed(TEMPLATE_WIDTH)) .into(), ); let name = category.name.clone(); cells.push( - pick_list( - template_options.clone(), - Some(category.list_template_slug.clone()), - move |value| { - Message::Settings(SettingsMsg::CategoryListTemplateChanged( - name.clone(), - value, - )) - }, + keyboard::focusable( + pick_list( + template_options.clone(), + Some(category.list_template_slug.clone()), + move |value| { + Message::Settings(SettingsMsg::CategoryListTemplateChanged( + name.clone(), + value, + )) + }, + ) + .padding([7, 9]) + .style(inputs::select_style) + .width(Length::Fixed(TEMPLATE_WIDTH)), + true, ) - .padding([7, 9]) - .style(inputs::select_style) - .width(Length::Fixed(TEMPLATE_WIDTH)) .into(), ); cells.push( container( row![ - button(text(t(locale, "common.save")).size(12)) + keyboard::button(text(t(locale, "common.save")).size(12)) .on_press(Message::Settings(SettingsMsg::SaveCategory( category.name.clone(), ))) .style(inputs::primary_button) .padding([6, 10]), - button(text(t(locale, "common.remove")).size(12)) + keyboard::button(text(t(locale, "common.remove")).size(12)) .on_press_maybe((!category.is_protected).then(|| Message::Settings( SettingsMsg::RemoveCategory(category.name.clone()), ))) @@ -336,11 +348,11 @@ fn view_categories<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen &state.new_category_name, |value| Message::Settings(SettingsMsg::AddCategoryNameChanged(value)), ), - button(text(t(locale, "common.add")).size(13)) + keyboard::button(text(t(locale, "common.add")).size(13)) .on_press(Message::Settings(SettingsMsg::AddCategory)) .style(inputs::primary_button) .padding([6, 12]), - button(text(t(locale, "settings.resetCategories")).size(13)) + keyboard::button(text(t(locale, "settings.resetCategories")).size(13)) .on_press(Message::Settings(SettingsMsg::ResetCategoriesToDefaults)) .style(inputs::secondary_button) .padding([6, 12]), @@ -382,7 +394,7 @@ fn view_categories<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen } fn section_tab<'a>(label: &str, active: bool, section: TagsSection) -> Element<'a, Message> { - button(text(label.to_string()).size(13)) + keyboard::button(text(label.to_string()).size(13)) .on_press(Message::Tags(TagsMsg::SetSection(section))) .padding([6, 12]) .style(if active { @@ -443,7 +455,7 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes .selected_tags .iter() .any(|selected_id| selected_id == &tag.id); - button( + keyboard::button( row![ text(&tag.name).size(font_size).color(Color::WHITE), text(post_count.to_string()) @@ -488,7 +500,7 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes )) .size(12) .color(Color::from_rgb(0.75, 0.77, 0.82)), - button(text(t(locale, "tags.clearSelection")).size(12)) + keyboard::button(text(t(locale, "tags.clearSelection")).size(12)) .on_press(Message::Tags(TagsMsg::ClearSelection)) .style(inputs::secondary_button) .padding([4, 8]), @@ -533,7 +545,7 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me |value| Message::Tags(TagsMsg::CreateColorChanged(value)), ), color_swatches(locale, true), - button(text(t(locale, "tags.createButton")).size(13)) + keyboard::button(text(t(locale, "tags.createButton")).size(13)) .on_press_maybe( (!state.create_name.trim().is_empty()).then_some(Message::Tags(TagsMsg::CreateTag)) ) @@ -573,7 +585,7 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me .get(&tag.name.to_lowercase()) .copied() .unwrap_or(0); - button( + keyboard::button( row![ container(Space::new(12, 12)).style(move |_: &Theme| container::Style { background: Some(Background::Color(color)), @@ -620,7 +632,7 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me .iter() .find(|option| option.slug == editing.template_slug); let delete_button: Element<'a, Message> = - button(text(t(locale, "modal.confirmDelete.delete")).size(13)) + keyboard::button(text(t(locale, "modal.confirmDelete.delete")).size(13)) .on_press(Message::Tags(TagsMsg::DeleteTag(editing.id.clone()))) .style(inputs::danger_button) .padding([6, 16]) @@ -645,7 +657,7 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me |choice| Message::Tags(TagsMsg::EditTagTemplate(choice)), ), row![ - button(text(t(locale, "common.save")).size(13)) + keyboard::button(text(t(locale, "common.save")).size(13)) .on_press(Message::Tags(TagsMsg::SaveTag)) .style(inputs::primary_button) .padding([6, 16]), @@ -744,7 +756,7 @@ fn view_merge<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes .padding([4, 0]) .into() }, - button(text(t(locale, "tags.merge")).size(13)) + keyboard::button(text(t(locale, "tags.merge")).size(13)) .on_press_maybe( state .merge_target @@ -768,7 +780,7 @@ fn view_discover<'a>(_state: &'a TagsViewState, locale: UiLocale) -> Element<'a, 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)) + keyboard::button(text(t(locale, "tags.discoverButton")).size(13)) .on_press(Message::Tags(TagsMsg::SyncTags)) .style(inputs::primary_button) .padding([6, 16]), @@ -819,7 +831,7 @@ fn color_swatches<'a>(locale: UiLocale, create_mode: bool) -> Element<'a, Messag } else { TagsMsg::EditTagColor((*hex).to_string()) }; - button(Space::new(18, 18)) + keyboard::button(Space::new(18, 18)) .on_press(Message::Tags(msg)) .padding(0) .style(move |_theme: &Theme, _status| button::Style { diff --git a/crates/bds-ui/src/views/template_editor.rs b/crates/bds-ui/src/views/template_editor.rs index 3b8d985..458b554 100644 --- a/crates/bds-ui/src/views/template_editor.rs +++ b/crates/bds-ui/src/views/template_editor.rs @@ -1,6 +1,7 @@ +use crate::components::keyboard; use std::cell::RefCell; -use iced::widget::{Space, button, column, container, row, scrollable, text}; +use iced::widget::{Space, column, container, row, scrollable, text}; use iced::{Color, Element, Length, Theme}; use bds_core::i18n::UiLocale; @@ -114,17 +115,17 @@ pub fn view<'a>(state: &'a TemplateEditorState, locale: UiLocale) -> Element<'a, status_badge(&state.status), ], vec![ - button(text(t(locale, "common.save")).size(13)) + keyboard::button(text(t(locale, "common.save")).size(13)) .on_press(Message::TemplateEditor(TemplateEditorMsg::Save)) .style(inputs::primary_button) .padding([6, 16]) .into(), - button(text(t(locale, "editor.validate")).size(13)) + keyboard::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)) + keyboard::button(text(t(locale, "modal.confirmDelete.delete")).size(13)) .on_press(Message::TemplateEditor(TemplateEditorMsg::Delete)) .style(inputs::danger_button) .padding([6, 16]) diff --git a/crates/bds-ui/src/views/toast.rs b/crates/bds-ui/src/views/toast.rs index 8967205..a0e0bef 100644 --- a/crates/bds-ui/src/views/toast.rs +++ b/crates/bds-ui/src/views/toast.rs @@ -1,5 +1,7 @@ +use crate::components::keyboard; +use iced::widget::button; use iced::widget::text::Shaping; -use iced::widget::{Space, button, container, row, text}; +use iced::widget::{Space, container, row, text}; use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Theme}; use crate::app::Message; @@ -62,7 +64,7 @@ pub fn view(toasts: &[Toast]) -> Option> { .iter() .map(|toast| { let level = toast.level; - let dismiss = button(text("\u{2715}").size(11).shaping(Shaping::Advanced)) + let dismiss = keyboard::button(text("\u{2715}").size(11).shaping(Shaping::Advanced)) .on_press(Message::DismissToast(toast.id)) .padding([2, 4]) .style(dismiss_btn); diff --git a/crates/bds-ui/src/views/translation_validation.rs b/crates/bds-ui/src/views/translation_validation.rs index 5336a7c..4bc6f75 100644 --- a/crates/bds-ui/src/views/translation_validation.rs +++ b/crates/bds-ui/src/views/translation_validation.rs @@ -1,5 +1,6 @@ +use crate::components::keyboard; use iced::widget::text::Shaping; -use iced::widget::{Space, button, column, container, row, scrollable, text}; +use iced::widget::{Space, column, container, row, scrollable, text}; use iced::{Color, Element, Length}; use bds_core::engine::validate_translations::{ @@ -19,7 +20,7 @@ pub struct TranslationValidationState { } pub fn view<'a>(state: &'a TranslationValidationState, locale: UiLocale) -> Element<'a, Message> { - let run = button(text(t(locale, "translationValidation.run")).size(13)) + let run = keyboard::button(text(t(locale, "translationValidation.run")).size(13)) .on_press_maybe((!state.is_running).then_some(Message::ValidateTranslations)) .style(inputs::primary_button) .padding([6, 16]); diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index 63b4c9c..05cb6f1 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -1,8 +1,9 @@ +use crate::components::keyboard; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use iced::widget::text::Shaping; -use iced::widget::{Space, button, column, container, mouse_area, row, stack, text}; +use iced::widget::{Space, column, container, mouse_area, row, stack, text}; use iced::{Alignment, Background, Color, Element, Length, Padding, Theme}; use bds_core::engine::git::GitCommit; @@ -305,7 +306,7 @@ pub fn view<'a>( .map(|&l| { let flag_text = text(l.flag_emoji()).size(16).shaping(Shaping::Advanced); - button(flag_text) + keyboard::button(flag_text) .on_press(Message::SetUiLocale(l)) .padding([4, 8]) .style(status_bar::dropdown_item) @@ -371,15 +372,32 @@ pub fn view<'a>( None }; + // Only the top blocking layer participates in keyboard traversal/access keys. + let modal_is_active = active_modal.is_some(); + let blocking_overlay = modal_is_active || overlay.is_some(); + let base_layout: Element<'a, Message> = if blocking_overlay { + keyboard::suspend(base_layout).into() + } else { + base_layout + }; + // Collect overlays: dropdowns and toasts let mut overlays: Vec> = Vec::new(); if let Some(toast_overlay) = toast::view(toasts) { - overlays.push(toast_overlay); + overlays.push(if blocking_overlay { + keyboard::suspend(toast_overlay).into() + } else { + toast_overlay + }); } if let Some(overlay) = overlay { - overlays.push(overlay); + overlays.push(if modal_is_active { + keyboard::suspend(overlay).into() + } else { + overlay + }); } // Modal overlay (highest z-index) diff --git a/specs/editor_misc.allium b/specs/editor_misc.allium index d9ffda9..fa9efbd 100644 --- a/specs/editor_misc.allium +++ b/specs/editor_misc.allium @@ -342,6 +342,8 @@ surface MetadataDiffSurface { @guarantee ScanAction -- Scan/Rescan button at top. -- Progress bar + message during scan. + -- Control+Option+R (macOS) or Control+Alt+R runs the scan; repair buttons use Tab + -- and are activated with Enter or Space. @guarantee EntityTabs -- Tabs: Posts, Media, Scripts, Templates — each with badge count of diffs. @@ -443,6 +445,8 @@ surface SiteValidationSurface { @guarantee ApplyAction -- Apply button disabled when nothing to fix. + -- Control+Option (macOS) or Control+Alt, followed by R, scans and A applies; + -- both remain in Tab order. -- On apply: renders missing, deletes extra, re-renders updated. -- Toast: "Validation applied: N rendered, N deleted". } diff --git a/specs/editor_post.allium b/specs/editor_post.allium index 2e578eb..3ca3f68 100644 --- a/specs/editor_post.allium +++ b/specs/editor_post.allium @@ -131,6 +131,7 @@ surface PostEditorSurface { @guarantee MetadataSection -- Collapsible section. Starts expanded when title is empty. + -- Control+Option+M (macOS) or Control+Alt+M toggles it outside focused text input. -- Two-column layout. -- Left column: Title, Tags, Author, Language + detect button, -- Do Not Translate checkbox, Slug (read-only), Categories, @@ -156,6 +157,7 @@ surface PostEditorSurface { @guarantee ExcerptSection -- Collapsible section with textarea (4 rows). + -- Control+Option+X (macOS) or Control+Alt+X toggles it outside focused text input. @guarantee EditorBodyToolbar -- Toolbar: "Content" label, mode toggle (Markdown/Preview), diff --git a/specs/editor_settings.allium b/specs/editor_settings.allium index 0526c91..96a0247 100644 --- a/specs/editor_settings.allium +++ b/specs/editor_settings.allium @@ -219,6 +219,7 @@ surface SettingsViewSurface { @guarantee CollapsibleSections -- All 8 sections are collapsible. -- Section visibility respects search filter. + -- Each visible section exposes a unique Control+Option or Control+Alt access key. } -- ─── Settings view actions ────────────────────────────────── diff --git a/specs/import.allium b/specs/import.allium index 07cbed1..2de5bd1 100644 --- a/specs/import.allium +++ b/specs/import.allium @@ -136,6 +136,8 @@ surface WordPressImportSurface { -- taxonomy, and discovers WordPress shortcodes. The saved report shows -- item counts, conflicts, missing uploads, year distribution, macro -- usage, and importable totals before any project content is changed. + -- Its Conflicts, Posts, Pages, Media, Taxonomy, and Macros sections + -- expose unique Control+Option or Control+Alt access keys for disclosure control. @guarantee ConflictReview -- Conflicts default to ignore. The operator can keep ignoring, overwrite diff --git a/specs/layout.allium b/specs/layout.allium index a856f84..d1424c3 100644 --- a/specs/layout.allium +++ b/specs/layout.allium @@ -72,6 +72,26 @@ surface AppShellSurface { shell.sidebar.width shell.content_area.panel.visible shell.assistant_sidebar.visible + + @guarantee KeyboardTraversal + -- Tab and Shift+Tab traverse every enabled button, checkbox, picker, + -- and text editor in visual order. Disabled controls are skipped. + -- Enter or Space activates the focused control. + + @guarantee KeyboardHints + -- Holding Control+Option on macOS or Control+Alt on Linux/Windows reveals + -- an access-key marker on every enabled control intersecting the viewport. + -- Semantic controls use their direct one-character shortcut. Remaining + -- visible controls receive stable two- or three-letter A-Z codes in + -- widget-tree order, excluding direct letters as generic prefixes. + -- Every marker is a warm-yellow box with black text and border, placed + -- wholly inside its control without obscuring activity-bar icons. + -- Typing a prefix while holding the modifier hides non-matches and shows + -- only each candidate's remaining suffix; a complete code activates its + -- control. Escape or modifier release cancels the access-key mode. + -- Covered layers and off-screen scroll content receive no code. A focused + -- Option/Alt alone always remains available for character input, and a + -- focused text editor retains modified input instead of entering the mode. } value ContentArea { @@ -162,6 +182,7 @@ value ActivityButton { label_key: String -- i18n key for tooltip badge: Badge? -- only git has a badge active: Boolean -- highlighted when this view is showing + access_key: String -- Control+Option (macOS) or Control+Alt + 1..9,0 in displayed order } value Badge { @@ -317,3 +338,9 @@ value TokenUsage { -- Ctrl/Cmd+B: toggle sidebar -- Ctrl/Cmd+W: close active tab (see tabs.allium) +-- Tab / Shift+Tab: next / previous enabled control +-- Enter / Space: activate focused control +-- Control+Option (macOS) or Control+Alt + 1..9,0: activate the corresponding activity +-- Post editor Control+Option (macOS) or Control+Alt + M/X: toggle metadata/excerpt +-- Import and Settings sections expose unique Control+Option or Control+Alt access keys +-- Site Validation uses R/A and Metadata Diff uses R with the platform modifier pair