diff --git a/src/shell/element/stack.rs b/src/shell/element/stack.rs index a4e564443..5c19a30a9 100644 --- a/src/shell/element/stack.rs +++ b/src/shell/element/stack.rs @@ -92,6 +92,7 @@ impl fmt::Debug for CosmicStack { #[derive(Debug, Clone)] pub struct CosmicStackInternal { windows: Arc>>, + tab_models: Arc<[tab::Model]>, active: Arc, activated: Arc, group_focused: Arc, @@ -107,6 +108,77 @@ pub struct CosmicStackInternal { } impl CosmicStackInternal { + pub fn set_previous_index(&self, moved_into: Option<&Seat>) -> Option { + let last_mod_serial = moved_into.and_then(|seat| seat.last_modifier_change()); + let mut prev_idx = self.previous_index.lock().unwrap(); + if !prev_idx.is_some_and(|(serial, _)| Some(serial) == last_mod_serial) { + *prev_idx = last_mod_serial.map(|s| (s, self.active.load(Ordering::SeqCst))); + } + prev_idx.map(|(_, idx)| idx) + } + + #[must_use] + pub fn add_window(&self, idx: Option, window: CosmicSurface) -> Message { + window.send_configure(); + self.scroll_to_focus.store(true, Ordering::SeqCst); + let model = tab::Model::from(&window); + let mut windows = self.windows.lock().unwrap(); + if let Some(idx) = idx { + windows.insert(idx, window); + let prev_active = self.active.swap(idx, Ordering::SeqCst); + if prev_active == idx { + self.reenter.store(true, Ordering::SeqCst); + self.previous_keyboard.store(prev_active, Ordering::SeqCst); + } + + Message::TabInsert(idx, model) + } else { + windows.push(window); + self.active.store(windows.len() - 1, Ordering::SeqCst); + Message::TabAdd(model) + } + } + + #[must_use] + pub fn remove_window(&self, idx: usize) -> Option { + let mut windows = self.windows.lock().unwrap(); + + if windows.len() == 1 { + self.override_alive.store(false, Ordering::SeqCst); + let window = &windows[0]; + window.try_force_undecorated(false); + window.set_tiled(false); + return None; + } + + if windows.len() <= idx { + return None; + } + + if idx == self.active.load(Ordering::SeqCst) { + self.reenter.store(true, Ordering::SeqCst); + } + + let window = windows.remove(idx); + window.try_force_undecorated(false); + window.set_tiled(false); + + _ = self + .active + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |active| { + if active == idx { + self.scroll_to_focus.store(true, Ordering::SeqCst); + Some(windows.len() - 1) + } else if idx < active { + Some(active - 1) + } else { + None + } + }); + + Some(window) + } + pub fn swap_focus(&self, focus: Option) -> Option { let value = focus.map_or(0, |x| x as u8); unsafe { Focus::from_u8(self.pointer_entered.swap(value, Ordering::SeqCst)) } @@ -132,7 +204,11 @@ impl CosmicStack { handle: LoopHandle<'static, crate::state::State>, theme: cosmic::Theme, ) -> CosmicStack { - let windows = windows.map(Into::into).collect::>(); + let (tab_models, windows) = windows + .map(Into::into) + .map(|window| (tab::Model::from(&window), window)) + .collect::<(Vec<_>, Vec<_>)>(); + assert!(!windows.is_empty()); for window in &windows { @@ -145,6 +221,7 @@ impl CosmicStack { CosmicStack(IcedElement::new( CosmicStackInternal { windows: Arc::new(Mutex::new(windows)), + tab_models: Arc::from(tab_models), active: Arc::new(AtomicUsize::new(0)), activated: Arc::new(AtomicBool::new(false)), group_focused: Arc::new(AtomicBool::new(false)), @@ -173,90 +250,59 @@ impl CosmicStack { let window = window.into(); window.try_force_undecorated(true); window.set_tiled(true); - self.0.with_program(|p| { - let last_mod_serial = moved_into.and_then(|seat| seat.last_modifier_change()); - let mut prev_idx = p.previous_index.lock().unwrap(); - if !prev_idx.is_some_and(|(serial, _)| Some(serial) == last_mod_serial) { - *prev_idx = last_mod_serial.map(|s| (s, p.active.load(Ordering::SeqCst))); - } + let message = self.0.with_program(|p| { + p.set_previous_index(moved_into); if let Some(mut geo) = p.geometry.lock().unwrap().clone() { geo.loc.y += TAB_HEIGHT; geo.size.h -= TAB_HEIGHT; window.set_geometry(geo, TAB_HEIGHT as u32); } - window.send_configure(); - if let Some(idx) = idx { - p.windows.lock().unwrap().insert(idx, window); - let old_idx = p.active.swap(idx, Ordering::SeqCst); - if old_idx == idx { - p.reenter.store(true, Ordering::SeqCst); - p.previous_keyboard.store(old_idx, Ordering::SeqCst); - } - } else { - let mut windows = p.windows.lock().unwrap(); - windows.push(window); - p.active.store(windows.len() - 1, Ordering::SeqCst); - } - p.scroll_to_focus.store(true, Ordering::SeqCst); + + p.add_window(idx, window) }); + + self.0.queue_message(message); self.0 .resize(Size::from((self.active().geometry().size.w, TAB_HEIGHT))); - self.0.force_redraw() } pub fn remove_window(&self, window: &CosmicSurface) { - self.0.with_program(|p| { - let mut windows = p.windows.lock().unwrap(); - if windows.len() == 1 { - p.override_alive.store(false, Ordering::SeqCst); - let window = windows.get(0).unwrap(); - window.try_force_undecorated(false); - window.set_tiled(false); - return; - } + let message = self.0.with_program(|p| { + let windows = p.windows.lock().unwrap(); + let idx = windows.iter().position(|w| w == window)?; + drop(windows); + p.remove_window(idx) + .is_some() + .then(|| Message::TabRemove(idx)) + }); - let Some(idx) = windows.iter().position(|w| w == window) else { - return; - }; - if idx == p.active.load(Ordering::SeqCst) { - p.reenter.store(true, Ordering::SeqCst); - } - let window = windows.remove(idx); - window.try_force_undecorated(false); - window.set_tiled(false); + if let Some(message) = message { + self.0.queue_message(message); + } - p.active.fetch_min(windows.len() - 1, Ordering::SeqCst); - }); self.0 .resize(Size::from((self.active().geometry().size.w, TAB_HEIGHT))); self.0.force_redraw() } pub fn remove_idx(&self, idx: usize) -> Option { - let window = self.0.with_program(|p| { - let mut windows = p.windows.lock().unwrap(); - if windows.len() == 1 { - p.override_alive.store(false, Ordering::SeqCst); - let window = windows.get(0).unwrap(); - window.try_force_undecorated(false); - window.set_tiled(false); - return Some(window.clone()); - } - if windows.len() <= idx { - return None; - } - if idx == p.active.load(Ordering::SeqCst) { - p.reenter.store(true, Ordering::SeqCst); + let (message, window) = self.0.with_program(|p| match p.remove_window(idx) { + Some(window) => (Some(Message::TabRemove(idx)), Some(window)), + None => { + let windows = p.windows.lock().unwrap(); + if windows.len() == 1 { + (None, Some(windows[0].clone())) + } else { + (None, None) + } } - let window = windows.remove(idx); - window.try_force_undecorated(false); - window.set_tiled(false); + }); - p.active.fetch_min(windows.len() - 1, Ordering::SeqCst); + if let Some(message) = message { + self.0.queue_message(message); + } - Some(window) - }); self.0 .resize(Size::from((self.active().geometry().size.w, TAB_HEIGHT))); self.0.force_redraw(); @@ -438,6 +484,7 @@ impl CosmicStack { }); if !matches!(result, MoveResult::Default) { + self.0.queue_message(Message::Refresh); self.0 .resize(Size::from((self.active().geometry().size.w, TAB_HEIGHT))); } @@ -787,14 +834,19 @@ impl CosmicStack { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub enum Message { DragStart, Menu, + TabAdd(tab::Model), TabMenu(usize), + TabInsert(usize, tab::Model), + TabRemove(usize), + TabSwap(usize, usize), PotentialTabDragStart(usize), Activate(usize), Close(usize), + Refresh, ScrollForward, ScrollBack, Scrolled, @@ -842,6 +894,15 @@ impl Program for CosmicStackInternal { last_seat: Option<&(Seat, Serial)>, ) -> Task { match message { + Message::Refresh => { + self.tab_models = self + .windows + .lock() + .unwrap() + .iter() + .map(tab::Model::from) + .collect(); + } Message::DragStart => { if let Some((seat, serial)) = last_seat.cloned() { let active = self.active.load(Ordering::SeqCst); @@ -952,6 +1013,28 @@ impl Program for CosmicStackInternal { } } } + Message::TabAdd(model) => { + let mut tab_models = Vec::with_capacity(self.tab_models.len() + 1); + tab_models.extend_from_slice(&self.tab_models); + tab_models.push(model); + self.tab_models = tab_models.into() + } + Message::TabInsert(idx, model) => { + let mut tab_models = Vec::with_capacity(self.tab_models.len() + 1); + tab_models.extend_from_slice(&self.tab_models); + tab_models.insert(idx, model); + self.tab_models = tab_models.into() + } + Message::TabRemove(idx) => { + let mut tab_models = Vec::from(self.tab_models.as_ref()); + _ = tab_models.remove(idx); + self.tab_models = tab_models.into(); + } + Message::TabSwap(from, to) => { + let mut tab_models = Vec::from(self.tab_models.as_ref()); + tab_models.swap(from, to); + self.tab_models = tab_models.into(); + } Message::TabMenu(idx) => { if let Some((seat, serial)) = last_seat.cloned() { if let Some(surface) = self.windows.lock().unwrap()[idx] @@ -1001,12 +1084,14 @@ impl Program for CosmicStackInternal { } fn view(&self) -> CosmicElement<'_, Self::Message> { - let windows = self.windows.lock().unwrap(); if self.geometry.lock().unwrap().is_none() { return iced_widget::row(Vec::new()).into(); }; + let active = self.active.load(Ordering::SeqCst); + let activated = self.activated.load(Ordering::SeqCst); let group_focused = self.group_focused.load(Ordering::SeqCst); + let maximized = self.windows.lock().unwrap()[active].is_maximized(false); let elements = vec![ cosmic_widget::icon::from_name("window-stack-symbolic") @@ -1033,20 +1118,14 @@ impl Program for CosmicStackInternal { .into(), CosmicElement::new( Tabs::new( - windows.iter().enumerate().map(|(i, w)| { - let user_data = w.user_data(); - user_data.insert_if_missing(Id::unique); - Tab::new( - w.title(), - w.app_id(), - user_data.get::().unwrap().clone(), - ) - .on_press(Message::PotentialTabDragStart(i)) - .on_right_click(Message::TabMenu(i)) - .on_close(Message::Close(i)) + self.tab_models.iter().enumerate().map(|(i, tab)| { + Tab::new(tab) + .on_press(Message::PotentialTabDragStart(i)) + .on_right_click(Message::TabMenu(i)) + .on_close(Message::Close(i)) }), active, - windows[active].is_activated(false), + activated, group_focused, ) .id(SCROLLABLE_ID.clone()) @@ -1068,7 +1147,7 @@ impl Program for CosmicStackInternal { .into(), ]; - let radius = if windows[active].is_maximized(false) { + let radius = if maximized { Radius::from(0.0) } else { Radius::from([8.0, 8.0, 0.0, 0.0]) @@ -1255,6 +1334,8 @@ impl SpaceElement for CosmicStack { SpaceElement::refresh(w) }); }); + + self.0.queue_message(Message::Refresh); SpaceElement::refresh(&self.0); } } diff --git a/src/shell/element/stack/tab.rs b/src/shell/element/stack/tab.rs index b2fcdba90..8dc8bcf76 100644 --- a/src/shell/element/stack/tab.rs +++ b/src/shell/element/stack/tab.rs @@ -1,23 +1,30 @@ +use std::hash::{Hash, Hasher}; + use cosmic::{ font::Font, iced::{ widget::{self, container::draw_background, rule::FillMode}, - Background, + Background, Padding, }, iced_core::{ alignment, event, layout::{Layout, Limits, Node}, mouse, overlay, renderer, - widget::{operation::Operation, tree::Tree, Id, Widget}, + widget::{ + operation::Operation, + tree::{self, Tree}, + Id, Widget, + }, Border, Clipboard, Color, Length, Rectangle, Shell, Size, }, iced_widget::scrollable::AbsoluteOffset, theme, - widget::{icon::from_name, Icon}, + widget::icon, Apply, }; use super::tab_text::tab_text; +use crate::shell::CosmicSurface; #[derive(Clone, Copy)] pub(super) enum TabRuleTheme { @@ -53,46 +60,52 @@ impl From for theme::Rule { #[derive(Clone, Copy)] pub(super) enum TabBackgroundTheme { + /// Selected active stack ActiveActivated, + /// Selected inactive stack ActiveDeactivated, + /// Not selected Default, } -impl From for theme::Container<'_> { - fn from(background_theme: TabBackgroundTheme) -> Self { - match background_theme { - TabBackgroundTheme::ActiveActivated => { - Self::custom(move |theme| widget::container::Style { - icon_color: Some(Color::from(theme.cosmic().accent_text_color())), - text_color: Some(Color::from(theme.cosmic().accent_text_color())), - background: Some(Background::Color( - theme.cosmic().primary.component.selected.into(), - )), - border: Border { - radius: 0.0.into(), - width: 0.0, - color: Color::TRANSPARENT, - }, - shadow: Default::default(), - }) - } - TabBackgroundTheme::ActiveDeactivated => { - Self::custom(move |theme| widget::container::Style { - icon_color: None, - text_color: None, - background: Some(Background::Color( - theme.cosmic().primary.component.base.into(), - )), - border: Border { - radius: 0.0.into(), - width: 0.0, - color: Color::TRANSPARENT, - }, - shadow: Default::default(), - }) +impl TabBackgroundTheme { + pub fn into_container_theme(self, hovered: bool) -> theme::Container<'static> { + let (selected, active_stack) = match self { + Self::ActiveActivated => (true, true), + Self::ActiveDeactivated => (true, false), + Self::Default => (false, false), + }; + + theme::Container::custom(move |theme| { + let cosmic_theme = theme.cosmic(); + + let text_color = if selected && active_stack { + Some(Color::from(cosmic_theme.accent_text_color())) + } else { + None + }; + + widget::container::Style { + icon_color: text_color, + text_color, + background: Some(Background::Color( + if hovered { + cosmic_theme.primary.component.hover_state_color() + } else if selected { + cosmic_theme.primary.component.selected_state_color() + } else { + cosmic_theme.primary.component.base + } + .into(), + )), + border: Border { + radius: 0.0.into(), + width: 0.0, + color: Color::TRANSPARENT, + }, + shadow: Default::default(), } - TabBackgroundTheme::Default => Self::Transparent, - } + }) } } @@ -105,10 +118,47 @@ pub trait TabMessage: Clone { fn scrolled() -> Self; } -pub struct Tab { - id: Id, - app_icon: Icon, - title: String, +#[derive(Debug, Clone)] +pub struct Model { + pub id: Id, + pub app_icon: cosmic::widget::icon::Handle, + pub title: String, + pub title_hash: u64, +} + +impl Model { + pub fn new(id: Id, appid: String, title: String) -> Self { + // Pre-emptively cache the hash of each title for more efficient diffing. + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + title.hash(&mut hasher); + + Self { + id, + app_icon: icon::from_name(appid).size(16).handle(), + title, + title_hash: hasher.finish(), + } + } +} + +impl From<&CosmicSurface> for Model { + fn from(window: &CosmicSurface) -> Self { + let user_data = window.user_data(); + user_data.insert_if_missing(Id::unique); + Self::new( + user_data.get::().unwrap().clone(), + window.app_id(), + window.title(), + ) + } +} + +struct LocalState { + hovered: bool, +} + +pub struct Tab<'a, Message: TabMessage> { + model: &'a Model, font: Font, close_message: Option, press_message: Option, @@ -118,12 +168,10 @@ pub struct Tab { active: bool, } -impl Tab { - pub fn new(title: impl Into, app_id: impl Into, id: Id) -> Self { +impl<'a, Message: TabMessage + 'static> Tab<'a, Message> { + pub fn new(model: &'a Model) -> Self { Tab { - id, - app_icon: from_name(app_id.into()).size(16).icon(), - title: title.into(), + model, font: cosmic::font::default(), close_message: None, press_message: None, @@ -164,18 +212,13 @@ impl Tab { self } - pub(super) fn non_active(mut self) -> Self { - self.active = false; - self - } - pub(super) fn active(mut self) -> Self { self.active = true; self } - pub(super) fn internal<'a>(self, idx: usize) -> TabInternal<'a, Message> { - let mut close_button = from_name("window-close-symbolic") + pub(super) fn internal(self, idx: usize) -> TabInternal<'a, Message> { + let mut close_button = icon::from_name("window-close-symbolic") .size(16) .prefer_svg(true) .icon() @@ -186,16 +229,16 @@ impl Tab { close_button = close_button.on_press(close_message); } - let items = vec![ + let items = [ widget::vertical_rule(4).class(self.rule_theme).into(), - self.app_icon + cosmic::widget::icon(self.model.app_icon.clone()) .clone() .apply(widget::container) .width(Length::Shrink) - .padding([2, 4]) + .padding([2, 4, 2, 8]) .center_y(Length::Fill) .into(), - tab_text(self.title, self.active) + tab_text(&self.model, self.active) .font(self.font) .font_size(14.0) .height(Length::Fill) @@ -204,17 +247,17 @@ impl Tab { close_button .apply(widget::container) .width(Length::Shrink) - .padding([2, 4]) + .padding([2, 12, 2, 4]) .center_y(Length::Fill) .align_x(alignment::Horizontal::Right) .into(), ]; TabInternal { - id: self.id, + id: self.model.id.clone(), idx, active: self.active, - background: self.background_theme.into(), + background: self.background_theme, elements: items, press_message: self.press_message, right_click_message: self.right_click_message, @@ -233,8 +276,8 @@ pub(super) struct TabInternal<'a, Message: TabMessage> { id: Id, idx: usize, active: bool, - background: theme::Container<'a>, - elements: Vec>, + background: TabBackgroundTheme, + elements: [cosmic::Element<'a, Message>; 4], press_message: Option, right_click_message: Option, } @@ -263,6 +306,10 @@ where Size::new(Length::Fill, Length::Fill) } + fn state(&self) -> tree::State { + tree::State::new(LocalState { hovered: false }) + } + fn layout(&self, tree: &mut Tree, renderer: &cosmic::Renderer, limits: &Limits) -> Node { let min_size = Size { height: TAB_HEIGHT as f32, @@ -286,14 +333,15 @@ where .min_height(size.height) .width(size.width) .height(size.height); + cosmic::iced_core::layout::flex::resolve( cosmic::iced_core::layout::flex::Axis::Horizontal, renderer, &limits, Length::Fill, Length::Fill, - 0.into(), - 8., + Padding::ZERO, + 0., cosmic::iced::Alignment::Center, if size.width >= CLOSE_BREAKPOINT as f32 { &self.elements @@ -337,6 +385,9 @@ where shell: &mut Shell<'_, Message>, viewport: &Rectangle, ) -> event::Status { + let state = tree.state.downcast_mut::(); + state.hovered = cursor.is_over(layout.bounds()); + let status = self .elements .iter_mut() @@ -356,7 +407,7 @@ where }) .fold(event::Status::Ignored, event::Status::merge); - if status == event::Status::Ignored && cursor.is_over(layout.bounds()) { + if status == event::Status::Ignored && state.hovered { if matches!( event, event::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) @@ -419,7 +470,10 @@ where viewport: &Rectangle, ) { use cosmic::widget::container::Catalog; - let style = theme.style(&self.background); + let state = tree.state.downcast_ref::(); + + let style = theme.style(&self.background.into_container_theme(state.hovered)); + let text_color = style.text_color.unwrap_or(renderer_style.text_color); draw_background(renderer, &style, layout.bounds()); @@ -434,8 +488,8 @@ where renderer, theme, &renderer::Style { - icon_color: style.text_color.unwrap_or(renderer_style.text_color), - text_color: style.text_color.unwrap_or(renderer_style.text_color), + icon_color: text_color, + text_color, scale_factor: renderer_style.scale_factor, }, layout, diff --git a/src/shell/element/stack/tab_text.rs b/src/shell/element/stack/tab_text.rs index ef5b38f3d..4abdb4ef5 100644 --- a/src/shell/element/stack/tab_text.rs +++ b/src/shell/element/stack/tab_text.rs @@ -13,9 +13,39 @@ use cosmic::{ }, }; +use super::tab::Model; + +const OVERLAY_DARK: Color = Color { + a: 1.0, + r: 0.149, + g: 0.149, + b: 0.149, +}; + +const OVERLAY_DARK_SELECTED: Color = Color { + a: 1.0, + r: 0.196, + g: 0.196, + b: 0.196, +}; + +const OVERLAY_LIGHT: Color = Color { + a: 1.0, + r: 0.894, + g: 0.894, + b: 0.894, +}; + +const OVERLAY_LIGHT_SELECTED: Color = Color { + a: 1.0, + r: 0.831, + g: 0.831, + b: 0.831, +}; + /// Text in a stack tab with an overflow gradient. -pub fn tab_text(text: String, selected: bool) -> TabText { - TabText::new(text, selected) +pub fn tab_text(model: &Model, selected: bool) -> TabText { + TabText::new(model, selected) } struct LocalState { @@ -25,8 +55,8 @@ struct LocalState { } /// Text in a stack tab with an overflow gradient. -pub struct TabText { - text: String, +pub struct TabText<'a> { + model: &'a Model, font: cosmic::font::Font, font_size: f32, selected: bool, @@ -34,15 +64,15 @@ pub struct TabText { width: Length, } -impl TabText { - pub fn new(text: String, selected: bool) -> Self { +impl<'a> TabText<'a> { + pub fn new(model: &'a Model, selected: bool) -> Self { TabText { width: Length::Shrink, height: Length::Shrink, font: cosmic::font::default(), font_size: 14.0, selected, - text, + model, } } @@ -70,14 +100,14 @@ impl TabText { fn create_hash(&self) -> u64 { let mut hasher = std::collections::hash_map::DefaultHasher::new(); - self.text.hash(&mut hasher); + self.model.title_hash.hash(&mut hasher); self.selected.hash(&mut hasher); hasher.finish() } fn create_paragraph(&self) -> ::Paragraph { ::Paragraph::with_text(Text { - content: &self.text, + content: &self.model.title, size: cosmic::iced_core::Pixels(self.font_size), bounds: Size::INFINITY, font: self.font, @@ -90,7 +120,7 @@ impl TabText { } } -impl Widget for TabText { +impl<'a, Message> Widget for TabText<'a> { fn tag(&self) -> tree::Tag { tree::Tag::of::() } @@ -142,54 +172,50 @@ impl Widget for TabText { renderer.with_layer(bounds, |renderer| { renderer.fill_paragraph( &state.paragraph, - Point::new(bounds.x, bounds.y + bounds.height / 2.0), + Point::new(bounds.x, bounds.height.mul_add(0.5, bounds.y)), style.text_color, bounds, ); - }); - if state.overflowed { - let background = if self.selected { - theme - .cosmic() - .primary - .component - .selected_state_color() - .into() - } else { - theme.cosmic().primary_container_color().into() - }; - let transparent = Color { - a: 0.0, - ..background - }; - - renderer.fill_quad( - renderer::Quad { - bounds: Rectangle { - x: (bounds.x + bounds.width - 24.).max(bounds.x), - width: 24.0_f32.min(bounds.width), - ..bounds - }, - border: Border { - radius: 0.0.into(), - width: 0.0, - color: Color::TRANSPARENT, - }, - shadow: Default::default(), - }, - Background::Gradient(Gradient::Linear( - gradient::Linear::new(Degrees(90.)) - .add_stop(0.0, transparent) - .add_stop(1.0, background), - )), - ); - } + renderer.with_layer(bounds, |renderer| { + if state.overflowed { + let overlay = match (theme.cosmic().is_dark, self.selected) { + (true, false) => OVERLAY_DARK, + (true, true) => OVERLAY_DARK_SELECTED, + (false, false) => OVERLAY_LIGHT, + (false, true) => OVERLAY_LIGHT_SELECTED, + }; + + let transparent = Color { a: 0.0, ..overlay }; + + renderer.fill_quad( + renderer::Quad { + bounds: Rectangle { + x: (bounds.x + bounds.width - 27.).max(bounds.x), + width: 27.0_f32.min(bounds.width), + ..bounds + }, + border: Border { + radius: 0.0.into(), + width: 0.0, + color: Color::TRANSPARENT, + }, + shadow: Default::default(), + }, + Background::Gradient(Gradient::Linear( + gradient::Linear::new(Degrees(90.)) + .add_stop(0.0, transparent) + .add_stop(1.0, overlay), + )), + ); + } + }); + }); } } -impl From for cosmic::Element<'_, Message> { - fn from(value: TabText) -> Self { +impl<'a, Message: 'static> From> for cosmic::Element<'a, Message> { + fn from(value: TabText<'a>) -> Self { Self::new(value) } } diff --git a/src/shell/element/stack/tabs.rs b/src/shell/element/stack/tabs.rs index e44a39cbc..dd4a07142 100644 --- a/src/shell/element/stack/tabs.rs +++ b/src/shell/element/stack/tabs.rs @@ -25,13 +25,14 @@ use keyframe::{ ease, functions::{EaseInOutCubic, EaseOutCubic}, }; +use smallvec::SmallVec; use std::{ collections::{HashMap, HashSet, VecDeque}, time::{Duration, Instant}, }; pub struct Tabs<'a, Message> { - elements: Vec>, + elements: SmallVec<[cosmic::Element<'a, Message>; 16]>, id: Option, height: Length, width: Length, @@ -58,6 +59,7 @@ struct TabAnimationState { #[derive(Debug, Clone)] pub struct State { offset_x: Offset, + scrolling: bool, scroll_animation: Option, scroll_to: Option, last_state: Option>, @@ -99,6 +101,7 @@ impl Default for State { fn default() -> Self { State { offset_x: Offset::Absolute(0.), + scrolling: false, scroll_animation: None, scroll_to: None, last_state: None, @@ -130,19 +133,19 @@ where Message: TabMessage + 'static, { pub fn new( - tabs: impl ExactSizeIterator>, + tabs: impl ExactSizeIterator>, active: usize, activated: bool, group_focused: bool, ) -> Self { - let tabs = tabs.into_iter().enumerate().map(|(i, tab)| { + let tabs = tabs.into_iter().enumerate().map(|(i, mut tab)| { let rule = if activated { TabRuleTheme::ActiveActivated } else { TabRuleTheme::ActiveDeactivated }; - let tab = if i == active { + tab = if i == active { tab.rule_style(rule) .background_style(if activated { TabBackgroundTheme::ActiveActivated @@ -152,9 +155,9 @@ where .font(cosmic::font::semibold()) .active() } else if i.checked_sub(1) == Some(active) { - tab.rule_style(rule).non_active() + tab.rule_style(rule) } else { - tab.non_active() + tab }; Element::new(tab.internal(i)) @@ -192,7 +195,7 @@ where .class(theme::iced::Button::Text) .on_press(Message::scroll_further()); - let mut elements = Vec::with_capacity(tabs.len() + 5); + let mut elements = SmallVec::with_capacity(tabs.len() + 5); elements.push(widget::vertical_rule(4).class(rule_style).into()); elements.push(prev_button.into()); @@ -342,6 +345,8 @@ where #[allow(clippy::too_many_lines)] fn layout(&self, tree: &mut Tree, renderer: &cosmic::Renderer, limits: &Limits) -> Node { + let state = tree.state.downcast_mut::(); + let limits = limits.width(self.width).height(self.height); // calculate the smallest possible size @@ -367,16 +372,24 @@ where height: a.height.max(b.height), }); let size = limits.resolve(self.width, self.height, min_size); - - if min_size.width <= size.width { + state.scrolling = min_size.width > size.width; + if !state.scrolling { // we don't need to scroll + nodes.clear(); + + // add placeholder nodes for the not rendered scroll-buttons/rules + let placeholder_node = Node::new(Size::new(0., 0.)); + let placeholder_node_with_child = + Node::with_children(Size::new(0., 0.), vec![Node::new(Size::new(0., 0.))]); + nodes.push(placeholder_node.clone()); + nodes.push(placeholder_node_with_child.clone()); // can we make every tab equal weight and keep the active large enough? - let children = if (size.width / (self.elements.len() as f32 - 5.)).ceil() as i32 + if (size.width / (self.elements.len() as f32 - 5.)).ceil() as i32 >= MIN_ACTIVE_TAB_WIDTH { // just use a flex layout - cosmic::iced_core::layout::flex::resolve( + let node = cosmic::iced_core::layout::flex::resolve( cosmic::iced_core::layout::flex::Axis::Horizontal, renderer, &limits, @@ -387,16 +400,16 @@ where cosmic::iced::Alignment::Center, &self.elements[2..self.elements.len() - 2], &mut tree.children[2..self.elements.len() - 2], - ) - .children() - .to_vec() + ); + + nodes.extend_from_slice(node.children()); } else { // otherwise we need a more manual approach let min_width = (size.width - MIN_ACTIVE_TAB_WIDTH as f32 - 4.) / (self.elements.len() as f32 - 6.); let mut offset = 0.; - let mut nodes = self.elements[2..self.elements.len() - 3] + let tab_nodes = self.elements[2..self.elements.len() - 3] .iter() .zip(tree.children[2..].iter_mut()) .map(|(tab, tab_tree)| { @@ -411,76 +424,51 @@ where node = node.move_to(Point::new(offset, 0.)); offset += node.bounds().width; node - }) - .collect::>(); + }); + + nodes.extend(tab_nodes); nodes.push({ let node = Node::new(Size::new(4., limits.max().height)); node.move_to(Point::new(offset, 0.)) }); - nodes }; - // and add placeholder nodes for the not rendered scroll-buttons/rules - Node::with_children( - size, - vec![ - Node::new(Size::new(0., 0.)), - Node::with_children(Size::new(0., 0.), vec![Node::new(Size::new(0., 0.))]), - ] - .into_iter() - .chain(children) - .chain(vec![ - Node::with_children(Size::new(0., 0.), vec![Node::new(Size::new(0., 0.))]), - Node::new(Size::new(0., 0.)), - ]) - .collect::>(), - ) + // add placeholder nodes for the not rendered scroll-buttons/rules + nodes.push(placeholder_node_with_child); + nodes.push(placeholder_node); } else { // we scroll, so use the computed min size, but add scroll buttons. let mut offset = 30.; for node in &mut nodes { - *node = node.clone().move_to(Point::new(offset, 0.)); + node.move_to_mut(Point::new(offset, 0.)); offset += node.bounds().width; } let last_position = Point::new(size.width - 34., 0.); nodes.remove(nodes.len() - 1); - Node::with_children( - size, - vec![Node::new(Size::new(4., size.height)), { - let mut node = Node::with_children( - Size::new(16., 16.), - vec![Node::new(Size::new(16., 16.))], - ); - node = node.move_to(Point::new(9., (size.height - 16.) / 2.)); - node - }] - .into_iter() - .chain(nodes) - .chain(vec![ - { - let mut node = Node::new(Size::new(4., size.height)); - node = node.move_to(last_position); - node - }, - { - let mut node = Node::with_children( - Size::new(16., 16.), - vec![Node::new(Size::new(16., 16.))], - ); - node = - node.move_to(last_position + Vector::new(9., (size.height - 16.) / 2.)); - node - }, - { - let mut node = Node::new(Size::new(4., size.height)); - node = node.move_to(last_position + Vector::new(30., 0.)); - node - }, - ]) - .collect(), - ) + // prepend nodes for the prev scroll button. + nodes.splice( + ..0, + vec![ + Node::new(Size::new(4., size.height)), + Node::with_children(Size::new(16., 16.), vec![Node::new(Size::new(16., 16.))]) + .move_to(Point::new(9., (size.height - 16.) / 2.)), + ], + ); + + // append nodes from the next scroll button. + nodes.push(Node::new(Size::new(4., size.height)).move_to(last_position)); + nodes.push( + Node::with_children(Size::new(16., 16.), vec![Node::new(Size::new(16., 16.))]) + .move_to(last_position + Vector::new(9., (size.height - 16.) / 2.)), + ); + + nodes.push( + Node::new(Size::new(4., size.height)).move_to(last_position + Vector::new(30., 0.)), + ); } + + Node::with_children(size, nodes) } #[allow(clippy::too_many_lines)] @@ -524,8 +512,7 @@ where ); draw_background(renderer, &background_style, bounds); - let scrolling = content_bounds.width.floor() > bounds.width; - if scrolling { + if state.scrolling { bounds.width -= 64.; bounds.x += 30.; } @@ -536,7 +523,7 @@ where ..bounds }; - if scrolling { + if state.scrolling { // we have scroll buttons for ((scroll, state), layout) in self .elements @@ -562,7 +549,7 @@ where / TAB_ANIMATION_DURATION.as_millis() as f32; ease(EaseOutCubic, 0.0, 1.0, percentage) } else { - 1.0 + 1.0f32 }; for ((tab, wstate), layout) in self.elements[2..self.elements.len() - 3] @@ -594,9 +581,9 @@ where height: layout.bounds().height, }); Rectangle { - x: previous.x + (next.x - previous.x) * percentage, - y: previous.y + (next.y - previous.y) * percentage, - width: previous.width + (next.width - previous.width) * percentage, + x: percentage.mul_add(next.x - previous.x, previous.x), + y: percentage.mul_add(next.y - previous.y, previous.y), + width: percentage.mul_add(next.width - previous.width, previous.width), height: next.height, } } else { @@ -640,7 +627,7 @@ where viewport, ); - if !scrolling && self.group_focused { + if !state.scrolling && self.group_focused { // HACK, overdraw our rule at the edges self.elements[0].as_widget().draw( &tree.children[2].children[0], @@ -662,7 +649,7 @@ where ); } - if scrolling { + if state.scrolling { // we have scroll buttons for ((scroll, state), layout) in self.elements [self.elements.len() - 2..self.elements.len()] @@ -732,7 +719,6 @@ where width: a.width + b.bounds().width, height: b.bounds().height, }); - let scrolling = content_bounds.width.floor() > bounds.width; let current_state = self.elements[2..self.elements.len() - 3] .iter() @@ -781,7 +767,7 @@ where }; if unknown_keys || changes.is_some() { - if !scrolling || !matches!(changes, Some(Difference::Focus)) { + if !state.scrolling || !matches!(changes, Some(Difference::Focus)) { let start_time = Instant::now(); State::discard_expired_tab_animations(&mut state.tab_animations, start_time); @@ -798,7 +784,7 @@ where *last_state = current_state; } - if scrolling { + if state.scrolling { bounds.x += 30.; bounds.width -= 64.; } @@ -808,7 +794,7 @@ where state.scroll_to = Some(idx); } if let Some(idx) = state.scroll_to.take() { - if scrolling { + if state.scrolling { let tab_bounds = layout.children().nth(idx + 2).unwrap().bounds(); let left_offset = tab_bounds.x - layout.bounds().x - 30.; let right_offset = left_offset + tab_bounds.width + 4.; @@ -850,7 +836,7 @@ where let mut internal_shell = Shell::new(&mut messages); let len = self.elements.len(); - let result = if scrolling && cursor.position().is_some_and(|pos| pos.x < bounds.x) { + let result = if state.scrolling && cursor.position().is_some_and(|pos| pos.x < bounds.x) { self.elements[0..2] .iter_mut() .zip(&mut tree.children) @@ -868,7 +854,7 @@ where ) }) .fold(event::Status::Ignored, event::Status::merge) - } else if scrolling + } else if state.scrolling && cursor .position() .is_some_and(|pos| pos.x >= bounds.x + bounds.width) @@ -945,9 +931,8 @@ where width: a.width + b.bounds().width, height: b.bounds().height, }); - let scrolling = content_bounds.width.floor() > bounds.width; - if scrolling { + if state.scrolling { bounds.width -= 64.; bounds.x += 30.; } @@ -958,7 +943,7 @@ where ..bounds }; - if scrolling && cursor.position().is_some_and(|pos| pos.x < bounds.x) { + if state.scrolling && cursor.position().is_some_and(|pos| pos.x < bounds.x) { self.elements[0..2] .iter() .zip(&tree.children) @@ -969,7 +954,7 @@ where .mouse_interaction(state, layout, cursor, viewport, renderer) }) .max() - } else if scrolling + } else if state.scrolling && cursor .position() .is_some_and(|pos| pos.x >= bounds.x + bounds.width)