yoda: toolbar as segmented_button for working drag reorder

The generic dnd_source+dnd_destination pairing didn't reliably fire
on intra-window reorders in this setup, while segmented_button's
built-in drag (same primitive powering tab_bar, which does work) is
proven. Switched the toolbar rendering to segmented_button::horizontal
with drag enabled — each segment carries its ToolbarAction as data.

App state:
- new toolbar_model: segmented_button::Model<SingleSelect>
- rebuild_toolbar_model() mirrors config.toolbar into the model on
  every update_config (including the initial app.update_config at
  startup)
- sync_toolbar_config_from_model() is the reverse: walk the model's
  entity order after a reorder, write the new Vec<ToolbarAction>
  directly via config.set_toolbar (without calling update_config so
  we don't rebuild the model and wipe the reorder the user just did)

Messages:
- ToolbarTabActivate(Entity): look up action via model.data(), clear
  the model's active selection (segmented_button single-select would
  keep the last click highlighted; we don't want that for action
  buttons), dispatch the action's message.
- ToolbarTabReorder(ReorderEvent): model.reorder then sync.

View:
- replaces the row-of-dnd-wrapped-icon-buttons with
  segmented_button::horizontal(&self.toolbar_model)
  .enable_tab_drag("x-cosmic-files/toolbar-dnd") .on_reorder(...) .on_activate(...)
- fixed 36-px square buttons so it still looks toolbar-y rather than
  stretched pill-segmented-control

Kept: Settings panel ↑↓/add/remove UI (no regression).
Removed: dnd_source/dnd_destination wrappers from the toolbar (but
the ToolbarActionPayload + MIME constant remain in case Settings DnD
gets unstuck later).
This commit is contained in:
Lionel DARNIS 2026-04-24 11:03:05 +02:00
parent af843d204d
commit 94c3e6c551

View file

@ -556,6 +556,10 @@ pub enum Message {
/// Move one step down (toward the end) inside the enabled toolbar list. /// Move one step down (toward the end) inside the enabled toolbar list.
ToolbarMoveDown(ToolbarAction), ToolbarMoveDown(ToolbarAction),
ToolbarReset, ToolbarReset,
/// Click on a toolbar button (via segmented_button activation).
ToolbarTabActivate(segmented_button::Entity),
/// Drag-reorder inside the toolbar (via segmented_button drag).
ToolbarTabReorder(segmented_button::ReorderEvent),
SetTypeToSearch(TypeToSearch), SetTypeToSearch(TypeToSearch),
SystemThemeModeChange, SystemThemeModeChange,
Size(window::Id, Size), Size(window::Id, Size),
@ -846,6 +850,11 @@ pub struct App {
nav_bar_context_id: segmented_button::Entity, nav_bar_context_id: segmented_button::Entity,
nav_model: segmented_button::SingleSelectModel, nav_model: segmented_button::SingleSelectModel,
tab_model: segmented_button::Model<segmented_button::SingleSelect>, tab_model: segmented_button::Model<segmented_button::SingleSelect>,
/// Yoda phase 3: segmented_button model mirroring config.toolbar so the
/// toolbar row gets free drag-reorder + click activation (same widget
/// that powers the tab bar, its reorder is proven to work in this
/// setup unlike the generic dnd_source/dnd_destination wrappers).
toolbar_model: segmented_button::Model<segmented_button::SingleSelect>,
config_handler: Option<cosmic_config::Config>, config_handler: Option<cosmic_config::Config>,
state_handler: Option<cosmic_config::Config>, state_handler: Option<cosmic_config::Config>,
config: Config, config: Config,
@ -1800,6 +1809,7 @@ impl App {
fn update_config(&mut self) -> Task<Message> { fn update_config(&mut self) -> Task<Message> {
self.update_nav_model(); self.update_nav_model();
self.rebuild_toolbar_model();
// Tabs are collected first to placate the borrowck // Tabs are collected first to placate the borrowck
let tabs: Box<[_]> = self.tab_model.iter().collect(); let tabs: Box<[_]> = self.tab_model.iter().collect();
// Update main conf and each tab with the new config // Update main conf and each tab with the new config
@ -1813,6 +1823,49 @@ impl App {
Task::batch(commands) Task::batch(commands)
} }
/// Yoda phase 3: rebuild `toolbar_model` so it matches `config.toolbar`.
/// Called on init and on every config update. Each entity carries the
/// associated `ToolbarAction` as data so click/reorder handlers can
/// round-trip Entity → ToolbarAction without maintaining a side map.
fn rebuild_toolbar_model(&mut self) {
self.toolbar_model.clear();
for action in self.config.toolbar.iter().copied() {
let (icon_name, label, _msg) = toolbar_action_ui(action);
self.toolbar_model
.insert()
.icon(widget::icon::from_name(icon_name).size(16).icon())
.text(label)
.data::<ToolbarAction>(action);
}
}
/// Yoda phase 3: after a drag-reorder, sync `config.toolbar` with the
/// new entity order in `toolbar_model`. Inlines what `config_set!`
/// would do (the macro lives inside update()).
fn sync_toolbar_config_from_model(&mut self) -> Task<Message> {
let new_toolbar: Vec<ToolbarAction> = self
.toolbar_model
.iter()
.filter_map(|e| self.toolbar_model.data::<ToolbarAction>(e).copied())
.collect();
if new_toolbar == self.config.toolbar {
return Task::none();
}
match self.config_handler.as_ref() {
Some(h) => {
if let Err(err) = self.config.set_toolbar(h, new_toolbar) {
log::warn!("failed to save toolbar order: {err}");
}
}
None => self.config.toolbar = new_toolbar,
}
// Don't call update_config() — that would rebuild the
// toolbar_model from config and undo the reorder the user just
// made. The model already has the new order; config is just
// catching up for persistence.
Task::none()
}
fn update_desktop(&mut self) -> Task<Message> { fn update_desktop(&mut self) -> Task<Message> {
let needs_reload: Box<[_]> = (self.tab_model.iter()) let needs_reload: Box<[_]> = (self.tab_model.iter())
.filter_map(|entity| { .filter_map(|entity| {
@ -2688,6 +2741,7 @@ impl Application for App {
nav_bar_context_id: segmented_button::Entity::null(), nav_bar_context_id: segmented_button::Entity::null(),
nav_model: segmented_button::ModelBuilder::default().build(), nav_model: segmented_button::ModelBuilder::default().build(),
tab_model: segmented_button::ModelBuilder::default().build(), tab_model: segmented_button::ModelBuilder::default().build(),
toolbar_model: segmented_button::ModelBuilder::default().build(),
config_handler: flags.config_handler, config_handler: flags.config_handler,
state_handler: flags.state_handler, state_handler: flags.state_handler,
config: flags.config, config: flags.config,
@ -4661,6 +4715,24 @@ impl Application for App {
config_set!(toolbar, default_toolbar()); config_set!(toolbar, default_toolbar());
return self.update_config(); return self.update_config();
} }
Message::ToolbarTabActivate(entity) => {
// Dispatch the stored ToolbarAction's message, then clear
// the "active" selection so the button doesn't stay
// highlighted after a click (we use segmented_button for
// layout/drag but toolbar buttons are action-firing, not
// a mutual-exclusive choice).
let action = self.toolbar_model.data::<ToolbarAction>(entity).copied();
self.toolbar_model.deactivate();
if let Some(action) = action {
let (_, _, msg) = toolbar_action_ui(action);
return self.update(msg);
}
return Task::none();
}
Message::ToolbarTabReorder(event) => {
let _ = self.toolbar_model.reorder(event.dragged, event.target, event.position);
return self.sync_toolbar_config_from_model();
}
Message::SetTypeToSearch(type_to_search) => { Message::SetTypeToSearch(type_to_search) => {
config_set!(type_to_search, type_to_search); config_set!(type_to_search, type_to_search);
return self.update_config(); return self.update_config();
@ -6805,52 +6877,23 @@ impl Application for App {
); );
} }
// Yoda phase 3: Dolphin-style quick actions toolbar. Items are // Yoda phase 3: Dolphin-style quick actions toolbar via
// rendered from self.config.toolbar (Vec<ToolbarAction>) — the user // segmented_button::horizontal — the same widget that powers the
// picks the set AND the order via direct drag-drop on the toolbar. // tab bar, so its built-in drag reorder works reliably (unlike the
// Short click = action (shared Action::message dispatch); drag past // generic dnd_source+dnd_destination pairing we tried earlier).
// the default 8px threshold = reorder (ToolbarReorder message). // Short click = action (ToolbarTabActivate → dispatch the stored
// ToolbarAction's message). Drag past threshold = reorder
// (ToolbarTabReorder → model.reorder + sync to config).
if !self.config.toolbar.is_empty() { if !self.config.toolbar.is_empty() {
use cosmic::iced::clipboard::dnd::DndAction as DndAct; let toolbar = widget::segmented_button::horizontal(&self.toolbar_model)
let clipboard_has = self.clipboard_has_content(); .button_height(32)
let buttons: Vec<Element<_>> = self .button_spacing(space_xxs)
.config .minimum_button_width(36)
.toolbar .maximum_button_width(36)
.iter() .enable_tab_drag(String::from("x-cosmic-files/toolbar-dnd"))
.map(|a| { .on_reorder(Message::ToolbarTabReorder)
let action = *a; .tab_drag_threshold(8.)
let (icon, label, msg) = toolbar_action_ui(action); .on_activate(Message::ToolbarTabActivate);
let enabled = !matches!(action, ToolbarAction::Paste) || clipboard_has;
let btn = widget::button::icon(widget::icon::from_name(icon).size(16));
let btn = if enabled { btn.on_press(msg) } else { btn };
let tooltip = widget::tooltip(
btn,
widget::text::body(label),
widget::tooltip::Position::Bottom,
);
let source = widget::dnd_source::<Message, ToolbarActionPayload>(tooltip)
.drag_content(move || ToolbarActionPayload(action.to_u8()));
widget::dnd_destination(
source,
vec![std::borrow::Cow::Borrowed(TOOLBAR_MIME)],
)
.data_received_for::<ToolbarActionPayload>(
move |payload: Option<ToolbarActionPayload>| {
match payload.and_then(|p| ToolbarAction::from_u8(p.0)) {
Some(src) if src != action => {
Message::ToolbarReorder { src, target: action }
}
_ => Message::ToolbarReorder { src: action, target: action },
}
},
)
.action(DndAct::Move)
.into()
})
.collect();
let toolbar = widget::row::with_children(buttons)
.spacing(space_xxs)
.align_y(Alignment::Center);
tab_column = tab_column.push( tab_column = tab_column.push(
widget::container(toolbar) widget::container(toolbar)
.width(Length::Fill) .width(Length::Fill)