Compare commits

...
Sign in to create a new pull request.

5 commits

Author SHA1 Message Date
5c3319351c header_bar: add WindowControlsPosition (macOS-style left controls)
Adds a new public enum `WindowControlsPosition { Start, End }` and a
matching field on `HeaderBar`, allowing window controls (close / minimize
/ maximize) to be packed on the start side of the headerbar (macOS
style, icon order close → minimize → maximize) instead of the default
end side (Linux / GNOME style, minimize → maximize → close).

Wiring:
- `crate::widget::WindowControlsPosition` re-exported alongside
  `HeaderBar`.
- `HeaderBar::controls_position(Option<WindowControlsPosition>)` setter;
  when left unset, falls back to `crate::config::window_controls_position()`
  (reads `CosmicTk.window_controls_position`), mirroring how `density`
  falls back to `header_size()`.
- New `CosmicTk.window_controls_position` field with default `End` for
  backwards compatibility; serde-friendly enum so existing configs keep
  working via `#[serde(default)]` semantics.

Tested with cosmic-yoterm, cosmic-settings, cosmic-edit, cosmic-files
rebuilt against this libcosmic via a local `[patch]` override. Config
changes picked up live through the existing cosmic-config subscription.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:08:12 +02:00
a322516f33 segmented_button: fix internal tab reorder end-to-end
Two independent bugs prevented tab drag-and-drop reorder from working
on cosmic-comp (and likely other compositors):

1. allow_reorder required DndAction::Move to be negotiated via
   OfferEvent::SelectedAction, which cosmic-comp does not always emit
   for self-drops (the SelectedAction event either never arrives or
   arrives with DndAction::empty()). Add a fallback: accept self-drops
   whenever state.dragging_tab is set. dragging_tab is only populated
   by start_tab_drag on this same widget, so this is safe; mime match
   and on_reorder presence are checked below.

2. reorder_event_for_drop preferred drop_hint.side over positional
   swap, producing counter-intuitive no-ops: dropping A (pos 0) on the
   left half of B (pos 1) resolved to "Before B" which, after removing
   A, lands at pos 0 again — the tab appeared not to move. Always use
   default_insert_position, which derives direction from dragged vs
   target positions (Konsole/Firefox/Chrome-style swap semantics).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:09:46 +02:00
108441ef61 segmented_button: add on_double_click callback
Fires in addition to on_activate when the same entity is left-clicked
twice within 400 ms. Lets applications bind quick actions (e.g. rename
a tab) without blocking the normal single-click activation path.

- New Widget::on_double_click builder mirroring on_activate/on_close.
- last_click field on LocalState for timestamp tracking.
- Detection branch in the mouse handler after on_activate fires, so the
  target entity is already focused when the callback runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:26:35 +02:00
1d98eee6de perf(widget): avoid VecDeque clone in segmented_button/table Model::clear
Model::clear() cloned the entire order VecDeque to iterate while
remove() mutated it, producing an O(n) allocation proportional to the
number of items — needless on a clear() which is going to drop all of
them anyway.

Replace the clone with std::mem::take(&mut self.order): we iterate the
taken VecDeque (transferring ownership), and the inner self.order.remove(index)
in each remove() call now finds position()==None and no-ops, since
self.order has been swapped with an empty default.

Same semantics, zero allocation. Noticeable on large nav/table models
(>100 items) and on apps that reset state frequently (settings pages,
file lists, context menus).
2026-04-19 16:29:02 +02:00
77262dd0af perf(malloc): throttle malloc_trim to 1 Hz in hot paths
malloc_trim(0) was called at the end of every update() and view(),
reaching 60-200 Hz during typical scrolling, resize, or animation.
Each call walks the glibc heap (10 us to several ms depending on
fragmentation) and could consume a substantial fraction of the frame
budget in worst cases.

Throttle trim() to once per second using a thread-local Instant,
preserving the existing API. RSS stays bounded (1 Hz is enough to
release collectable pages soon after) while per-frame cost becomes
a single thread-local check plus a duration comparison.

No call-site changes required; the three existing trim(0) invocations
in src/app/cosmic.rs (update, view multi-window, view single-window)
now fall under the throttle transparently.
2026-04-19 15:14:22 +02:00
7 changed files with 190 additions and 35 deletions

View file

@ -4,6 +4,7 @@
//! Configurations available to libcosmic applications.
use crate::cosmic_theme::Density;
use crate::widget::WindowControlsPosition;
use cosmic_config::cosmic_config_derive::CosmicConfigEntry;
use cosmic_config::{Config, CosmicConfigEntry};
use serde::{Deserialize, Serialize};
@ -67,6 +68,12 @@ pub fn header_size() -> Density {
COSMIC_TK.read().unwrap().header_size
}
/// Position of the window control buttons (close / minimize / maximize).
#[allow(clippy::missing_panics_doc)]
pub fn window_controls_position() -> WindowControlsPosition {
COSMIC_TK.read().unwrap().window_controls_position
}
/// Interface density.
#[allow(clippy::missing_panics_doc)]
pub fn interface_density() -> Density {
@ -109,6 +116,10 @@ pub struct CosmicTk {
/// Mono font family
pub monospace_font: FontConfig,
/// Side on which window control buttons (close / minimize / maximize)
/// are placed. `End` = right (Linux / GNOME), `Start` = left (macOS).
pub window_controls_position: WindowControlsPosition,
}
impl Default for CosmicTk {
@ -132,6 +143,7 @@ impl Default for CosmicTk {
stretch: iced::font::Stretch::Normal,
style: iced::font::Style::Normal,
},
window_controls_position: WindowControlsPosition::default(),
}
}
}

View file

@ -1,21 +1,49 @@
// Copyright 2025 System76 <info@system76.com>
// SPDX-License-Identifier: MPL-2.0
use std::cell::Cell;
use std::os::raw::c_int;
use std::time::{Duration, Instant};
const M_MMAP_THRESHOLD: c_int = -3;
/// Minimum interval between two actual `malloc_trim` calls.
///
/// `trim` is called at the end of every `update()` and `view()`, which can
/// reach 60-200 Hz during typical scrolling, resize, or animation. Each
/// `malloc_trim` walks the glibc heap (10 µs to several ms depending on
/// fragmentation), so calling it at render frequency can consume a
/// substantial fraction of the frame budget. Throttling to 1 Hz keeps RSS
/// bounded while removing the per-frame syscall from the hot path.
const TRIM_MIN_INTERVAL: Duration = Duration::from_millis(1000);
thread_local! {
static LAST_TRIM: Cell<Option<Instant>> = const { Cell::new(None) };
}
unsafe extern "C" {
fn malloc_trim(pad: usize);
fn mallopt(param: c_int, value: c_int) -> c_int;
}
/// Throttled wrapper over `malloc_trim`. Safe to call at render frequency:
/// consecutive calls within `TRIM_MIN_INTERVAL` (per-thread) skip the syscall.
#[inline]
pub fn trim(pad: usize) {
unsafe {
malloc_trim(pad);
}
LAST_TRIM.with(|last| {
let now = Instant::now();
let should_trim = match last.get() {
None => true,
Some(prev) => now.duration_since(prev) >= TRIM_MIN_INTERVAL,
};
if should_trim {
last.set(Some(now));
unsafe {
malloc_trim(pad);
}
}
});
}
/// Prevents glibc from hoarding memory via memory fragmentation.

View file

@ -27,9 +27,31 @@ pub fn header_bar<'a, Message>() -> HeaderBar<'a, Message> {
is_ssd: false,
on_double_click: None,
transparent: false,
controls_position: None,
}
}
/// Position of the window control buttons (close/min/max) within the headerbar.
#[derive(
Clone,
Copy,
Debug,
Default,
PartialEq,
Eq,
serde::Serialize,
serde::Deserialize,
)]
pub enum WindowControlsPosition {
/// Controls packed at the start (left on LTR) — macOS style.
/// Internal icon order becomes close → minimize → maximize.
Start,
/// Controls packed at the end (right on LTR) — Linux / GNOME style.
/// Internal icon order is minimize → maximize → close.
#[default]
End,
}
#[derive(Setters)]
pub struct HeaderBar<'a, Message> {
/// Defines the title of the window
@ -91,6 +113,14 @@ pub struct HeaderBar<'a, Message> {
/// Whether the headerbar should be transparent
transparent: bool,
/// Side on which the window control buttons (close / minimize / maximize)
/// are rendered. `None` falls back to the user's CosmicTk config
/// (`crate::config::window_controls_position()`). `Some` overrides it.
/// `End` matches Linux / GNOME conventions; `Start` provides macOS-style
/// left-side controls.
#[setters(strip_option)]
controls_position: Option<WindowControlsPosition>,
}
impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> {
@ -372,12 +402,20 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> {
} = theme::spacing();
// Take ownership of the regions to be packed.
let start = std::mem::take(&mut self.start);
let mut start = std::mem::take(&mut self.start);
let center = std::mem::take(&mut self.center);
let mut end = std::mem::take(&mut self.end);
// Also packs the window controls at the very end.
end.push(self.window_controls(space_xxs));
// Pack window controls on the configured side (reads CosmicTk
// config when the builder did not set an explicit override).
let controls_position = self
.controls_position
.unwrap_or_else(crate::config::window_controls_position);
let controls = self.window_controls(space_xxs, controls_position);
match controls_position {
WindowControlsPosition::End => end.push(controls),
WindowControlsPosition::Start => start.insert(0, controls),
}
let padding = if self.is_ssd {
[2, 8, 2, 8]
@ -447,7 +485,11 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> {
}
/// Creates the widget for window controls.
fn window_controls(&mut self, spacing: u16) -> Element<'a, Message> {
fn window_controls(
&mut self,
spacing: u16,
controls_position: WindowControlsPosition,
) -> Element<'a, Message> {
macro_rules! icon {
($name:expr, $size:expr, $on_press:expr) => {{
widget::icon::from_name($name)
@ -460,25 +502,37 @@ impl<'a, Message: Clone + 'static> HeaderBar<'a, Message> {
}};
}
widget::row::with_capacity(3)
.push_maybe(
self.on_minimize
.take()
.map(|m| icon!("window-minimize-symbolic", 16, m)),
)
.push_maybe(self.on_maximize.take().map(|m| {
if self.maximized {
icon!("window-restore-symbolic", 16, m)
} else {
icon!("window-maximize-symbolic", 16, m)
}
}))
.push_maybe(
self.on_close
.take()
.map(|m| icon!("window-close-symbolic", 16, m)),
)
.spacing(spacing)
let minimize = self
.on_minimize
.take()
.map(|m| icon!("window-minimize-symbolic", 16, m));
let maximize = self.on_maximize.take().map(|m| {
if self.maximized {
icon!("window-restore-symbolic", 16, m)
} else {
icon!("window-maximize-symbolic", 16, m)
}
});
let close = self
.on_close
.take()
.map(|m| icon!("window-close-symbolic", 16, m));
// Icon order follows the OS convention for the chosen side:
// End → minimize, maximize, close (Linux / GNOME)
// Start → close, minimize, maximize (macOS)
let row = widget::row::with_capacity(3);
let row = match controls_position {
WindowControlsPosition::End => row
.push_maybe(minimize)
.push_maybe(maximize)
.push_maybe(close),
WindowControlsPosition::Start => row
.push_maybe(close)
.push_maybe(minimize)
.push_maybe(maximize),
};
row.spacing(spacing)
.align_y(iced::Alignment::Center)
.into()
}

View file

@ -221,7 +221,7 @@ pub use grid::{Grid, grid};
mod header_bar;
#[doc(inline)]
pub use header_bar::{HeaderBar, header_bar};
pub use header_bar::{HeaderBar, WindowControlsPosition, header_bar};
pub mod icon;
#[doc(inline)]

View file

@ -132,7 +132,10 @@ where
/// ```
#[inline]
pub fn clear(&mut self) {
for entity in self.order.clone() {
// `remove()` mutates `self.order`, so transfer ownership first:
// the inner `self.order.remove(index)` then no-ops because
// `position()` can't find the entity in the empty VecDeque.
for entity in std::mem::take(&mut self.order) {
self.remove(entity);
}
}

View file

@ -173,6 +173,10 @@ where
pub(super) on_context: Option<Box<dyn Fn(Entity) -> Message + 'static>>,
#[setters(skip)]
pub(super) on_middle_press: Option<Box<dyn Fn(Entity) -> Message + 'static>>,
/// Emits the ID of the item that was double-clicked with the left button.
/// Fires in addition to `on_activate` (which keeps firing on each click).
#[setters(skip)]
pub(super) on_double_click: Option<Box<dyn Fn(Entity) -> Message + 'static>>,
#[setters(skip)]
pub(super) on_dnd_drop:
Option<Box<dyn Fn(Entity, Vec<u8>, String, DndAction) -> Message + 'static>>,
@ -232,6 +236,7 @@ where
on_close: None,
on_context: None,
on_middle_press: None,
on_double_click: None,
on_dnd_drop: None,
on_dnd_enter: None,
on_dnd_leave: None,
@ -354,6 +359,16 @@ where
self
}
/// Emitted when a tab is double-clicked with the left mouse button.
/// Fires in addition to `on_activate`, which keeps firing on each click.
pub fn on_double_click<T>(mut self, on_double_click: T) -> Self
where
T: Fn(Entity) -> Message + 'static,
{
self.on_double_click = Some(Box::new(on_double_click));
self
}
/// Enable drag-and-drop support for tabs using the provided payload builder.
pub fn enable_tab_drag(mut self, mime: String) -> Self {
self.tab_drag = Some(TabDragSource::new(mime));
@ -391,11 +406,12 @@ where
{
return None;
}
let position = state
.drop_hint
.filter(|hint| hint.entity == target)
.map(|hint| InsertPosition::from(hint.side))
.unwrap_or_else(|| self.default_insert_position(dragged, target));
// Always use positional swap (Konsole/Firefox/Chrome semantics):
// dropping onto any part of a different tab swaps it with the dragged
// tab. drop_hint.side-based Before/After is counter-intuitive: dropping
// A (pos 0) on the left half of B (pos 1) resolves to "Before B" which,
// after removing A, lands at pos 0 — so the tab appears not to move.
let position = self.default_insert_position(dragged, target);
Some(ReorderEvent {
dragged,
target,
@ -912,6 +928,7 @@ where
hovered: Default::default(),
known_length: Default::default(),
middle_clicked: Default::default(),
last_click: None,
internal_layout: Default::default(),
context_cursor: Point::default(),
show_context: Default::default(),
@ -1185,7 +1202,14 @@ where
.dnd_state
.drag_offer
.as_ref()
.is_some_and(|offer| offer.selected_action.contains(DndAction::Move));
.is_some_and(|offer| offer.selected_action.contains(DndAction::Move))
// Self-drop fallback: some compositors (cosmic-comp
// observed) do not emit OfferEvent::SelectedAction for
// internal drags, leaving selected_action empty.
// dragging_tab is only set by start_tab_drag on this
// same widget, so this covers the self-drop case
// safely; mime and on_reorder are checked below.
|| state.dragging_tab.is_some();
let pending_reorder = if allow_reorder
&& self.on_reorder.is_some()
&& self.tab_drag.as_ref().is_some_and(|d| d.mime == *mime_type)
@ -1383,6 +1407,33 @@ where
state.set_focused();
state.focused_item = Item::Tab(key);
state.pressed_item = None;
// Double-click detection on the same entity
// within 400 ms — fires after on_activate so
// the tab is already focused when the handler
// runs.
if let Some(on_double_click) =
self.on_double_click.as_ref()
{
let now = Instant::now();
let is_double = match state.last_click {
Some((prev, t)) => {
prev == key
&& now.duration_since(t)
< Duration::from_millis(400)
}
None => false,
};
state.last_click = if is_double {
None
} else {
Some((key, now))
};
if is_double {
shell.publish(on_double_click(key));
}
}
shell.capture_event();
return;
}
@ -2387,6 +2438,9 @@ pub struct LocalState {
hovered: Item,
/// The ID of the button that was middle-clicked, but not yet released.
middle_clicked: Option<Item>,
/// Entity and timestamp of the most recent left-click activation, used
/// to detect double-clicks on the same tab.
last_click: Option<(Entity, Instant)>,
/// Last known length of the model.
pub(super) known_length: usize,
/// Dimensions of internal buttons when shrinking
@ -2532,6 +2586,7 @@ mod tests {
hovered: Item::default(),
known_length: 0,
middle_clicked: None,
last_click: None,
internal_layout: Vec::new(),
context_cursor: Point::ORIGIN,
show_context: None,

View file

@ -100,7 +100,10 @@ where
/// model.clear();
/// ```
pub fn clear(&mut self) {
for entity in self.order.clone() {
// `remove()` mutates `self.order`, so transfer ownership first:
// the inner `self.order.remove(index)` then no-ops because
// `position()` can't find the entity in the empty VecDeque.
for entity in std::mem::take(&mut self.order) {
self.remove(entity);
}
}