feat(layout): overlay scrolling tab indicators and hover titles
This commit is contained in:
1 parent
a3974c2dda
commit
042698ea61
21 files changed
+1223
-23
No files matched your search
@@ -4,6 +4,7 @@ mod input_motion;
|
|||||||
mod layout;
|
mod layout;
|
||||||
mod loading;
|
mod loading;
|
||||||
mod outputs;
|
mod outputs;
|
||||||
|
mod tab_indicator;
|
||||||
mod xwayland;
|
mod xwayland;
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
@@ -28,6 +29,7 @@ use outputs::*;
|
|||||||
pub use outputs::{LidPolicy, OutputLayout, OutputModeRequest, OutputProfile, OutputSettings, OutputTransform};
|
pub use outputs::{LidPolicy, OutputLayout, OutputModeRequest, OutputProfile, OutputSettings, OutputTransform};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use smithay::input::keyboard::{Keycode, keysyms, xkb};
|
use smithay::input::keyboard::{Keycode, keysyms, xkb};
|
||||||
|
pub(crate) use tab_indicator::TabIndicatorSettings;
|
||||||
|
|
||||||
use crate::window_rules;
|
use crate::window_rules;
|
||||||
use crate::window_rules::{WindowRule, WindowRuleConfig};
|
use crate::window_rules::{WindowRule, WindowRuleConfig};
|
||||||
@@ -167,6 +169,7 @@ impl Config {
|
|||||||
focus_effect: self.focus_effect_settings()?,
|
focus_effect: self.focus_effect_settings()?,
|
||||||
default_column_width: self.default_column_width()?,
|
default_column_width: self.default_column_width()?,
|
||||||
default_column_display: self.default_column_display(),
|
default_column_display: self.default_column_display(),
|
||||||
|
tab_indicator: self.tab_indicator_settings()?,
|
||||||
scrolling_focus_strategy: self.scrolling_focus_strategy(),
|
scrolling_focus_strategy: self.scrolling_focus_strategy(),
|
||||||
column_width_presets: self.width_presets()?,
|
column_width_presets: self.width_presets()?,
|
||||||
animations_enabled: self.animations_enabled(),
|
animations_enabled: self.animations_enabled(),
|
||||||
@@ -230,6 +233,8 @@ enum LayoutModeValue {
|
|||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
struct ScrollingConfig {
|
struct ScrollingConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
tab_indicator: tab_indicator::TabIndicatorConfig,
|
||||||
default_column_display: Option<ColumnDisplayValue>,
|
default_column_display: Option<ColumnDisplayValue>,
|
||||||
default_column_width: Option<ColumnWidthValue>,
|
default_column_width: Option<ColumnWidthValue>,
|
||||||
focus_strategy: Option<FocusStrategyValue>,
|
focus_strategy: Option<FocusStrategyValue>,
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub(super) struct TabIndicatorConfig {
|
||||||
|
width: f64,
|
||||||
|
segment_height: f64,
|
||||||
|
segment_gap: f64,
|
||||||
|
hit_width: f64,
|
||||||
|
gap_threshold: f64,
|
||||||
|
list_width: f64,
|
||||||
|
row_height: f64,
|
||||||
|
padding: f64,
|
||||||
|
font_size: f64,
|
||||||
|
flash_duration_ms: u64,
|
||||||
|
active_color: Option<String>,
|
||||||
|
inactive_color: Option<String>,
|
||||||
|
background_color: Option<String>,
|
||||||
|
text_color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TabIndicatorConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
width: 4.0,
|
||||||
|
segment_height: 18.0,
|
||||||
|
segment_gap: 4.0,
|
||||||
|
hit_width: 12.0,
|
||||||
|
gap_threshold: 8.0,
|
||||||
|
list_width: 220.0,
|
||||||
|
row_height: 28.0,
|
||||||
|
padding: 4.0,
|
||||||
|
font_size: 12.0,
|
||||||
|
flash_duration_ms: 1000,
|
||||||
|
active_color: None,
|
||||||
|
inactive_color: None,
|
||||||
|
background_color: None,
|
||||||
|
text_color: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub(crate) struct TabIndicatorSettings {
|
||||||
|
pub width: f64,
|
||||||
|
pub segment_height: f64,
|
||||||
|
pub segment_gap: f64,
|
||||||
|
pub hit_width: f64,
|
||||||
|
pub gap_threshold: f64,
|
||||||
|
pub list_width: f64,
|
||||||
|
pub row_height: f64,
|
||||||
|
pub padding: f64,
|
||||||
|
pub font_size: f64,
|
||||||
|
pub flash_duration: std::time::Duration,
|
||||||
|
pub active_color: Option<[f32; 4]>,
|
||||||
|
pub inactive_color: Option<[f32; 4]>,
|
||||||
|
pub background_color: Option<[f32; 4]>,
|
||||||
|
pub text_color: Option<[f32; 4]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub(crate) fn tab_indicator_settings(&self) -> Result<TabIndicatorSettings, ConfigError> {
|
||||||
|
let c = &self.scrolling.tab_indicator;
|
||||||
|
let range = |value: f64, min: f64, max: f64, field: &'static str| {
|
||||||
|
if value.is_finite() && (min..=max).contains(&value) {
|
||||||
|
Ok(value)
|
||||||
|
} else {
|
||||||
|
Err(ConfigError::LayoutValue { field, value })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let color =
|
||||||
|
|value: &Option<String>, field| value.as_ref().map(|s| parse_color(s, field).map(|c| c.0)).transpose();
|
||||||
|
let width = range(c.width, 1.0, 16.0, "tab_indicator.width")?;
|
||||||
|
let hit_width = range(c.hit_width, width, 64.0, "tab_indicator.hit_width")?;
|
||||||
|
let font_size = range(c.font_size, 8.0, 32.0, "tab_indicator.font_size")?;
|
||||||
|
Ok(TabIndicatorSettings {
|
||||||
|
width,
|
||||||
|
hit_width,
|
||||||
|
font_size,
|
||||||
|
segment_height: range(c.segment_height, width, 128.0, "tab_indicator.segment_height")?,
|
||||||
|
segment_gap: range(c.segment_gap, 0.0, 64.0, "tab_indicator.segment_gap")?,
|
||||||
|
gap_threshold: range(c.gap_threshold, width, 128.0, "tab_indicator.gap_threshold")?,
|
||||||
|
list_width: range(c.list_width, 80.0, 640.0, "tab_indicator.list_width")?,
|
||||||
|
row_height: range(c.row_height, font_size + 6.0, 80.0, "tab_indicator.row_height")?,
|
||||||
|
padding: range(c.padding, 0.0, 24.0, "tab_indicator.padding")?,
|
||||||
|
flash_duration: std::time::Duration::from_millis(range(
|
||||||
|
c.flash_duration_ms as f64,
|
||||||
|
0.0,
|
||||||
|
60_000.0,
|
||||||
|
"tab_indicator.flash_duration_ms",
|
||||||
|
)? as u64),
|
||||||
|
active_color: color(&c.active_color, "tab_indicator.active_color")?,
|
||||||
|
inactive_color: color(&c.inactive_color, "tab_indicator.inactive_color")?,
|
||||||
|
background_color: color(&c.background_color, "tab_indicator.background_color")?,
|
||||||
|
text_color: color(&c.text_color, "tab_indicator.text_color")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn indicator_defaults_overrides_and_validation() {
|
||||||
|
let c = Config::parse_source("").unwrap().tab_indicator_settings().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(c.width, c.segment_height, c.segment_gap, c.hit_width, c.gap_threshold),
|
||||||
|
(4., 18., 4., 12., 8.)
|
||||||
|
);
|
||||||
|
assert_eq!(c.flash_duration.as_millis(), 1000);
|
||||||
|
assert!(c.active_color.is_none());
|
||||||
|
let config = Config::parse_source(
|
||||||
|
"scrolling { tab-indicator { width 3; hit-width 14; flash-duration-ms 750; active-color \"#ff0000\"; }; }",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let c = config.tab_indicator_settings().unwrap();
|
||||||
|
assert_eq!((c.width, c.hit_width, c.flash_duration.as_millis()), (3., 14., 750));
|
||||||
|
assert_eq!(c.active_color, Some([1., 0., 0., 1.]));
|
||||||
|
for source in [
|
||||||
|
"width 0",
|
||||||
|
"segment-gap -1",
|
||||||
|
"hit-width 2",
|
||||||
|
"row-height 8",
|
||||||
|
"flash-duration-ms 60001",
|
||||||
|
"active-color \"invalid\"",
|
||||||
|
] {
|
||||||
|
let config = Config::parse_source(&format!("scrolling {{ tab-indicator {{ {source}; }}; }}")).unwrap();
|
||||||
|
assert!(config.tab_indicator_settings().is_err(), "{source}");
|
||||||
|
}
|
||||||
|
assert!(Config::parse_source("scrolling { tab-indicator { typo 1; }; }").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -484,6 +484,7 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
let position = self.constrain_pointer_position(&pointer, position);
|
let position = self.constrain_pointer_position(&pointer, position);
|
||||||
|
|
||||||
|
self.update_tab_indicator_hover(position);
|
||||||
pointer.motion(
|
pointer.motion(
|
||||||
self,
|
self,
|
||||||
self.surface_under(position),
|
self.surface_under(position),
|
||||||
@@ -528,6 +529,7 @@ impl Ferese {
|
|||||||
let requested = self.clamp_pointer_position(position + delta);
|
let requested = self.clamp_pointer_position(position + delta);
|
||||||
let location = self.constrain_pointer_position(&pointer, requested);
|
let location = self.constrain_pointer_position(&pointer, requested);
|
||||||
|
|
||||||
|
self.update_tab_indicator_hover(location);
|
||||||
pointer.motion(
|
pointer.motion(
|
||||||
self,
|
self,
|
||||||
self.surface_under(location),
|
self.surface_under(location),
|
||||||
@@ -712,6 +714,12 @@ impl Ferese {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let pointer = self.seat.get_pointer().expect("seat has a pointer");
|
let pointer = self.seat.get_pointer().expect("seat has a pointer");
|
||||||
|
if self.scroll_tab_indicator(
|
||||||
|
pointer.current_location(),
|
||||||
|
if vertical != 0.0 { vertical } else { horizontal },
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if self.scroll_overview_strip(
|
if self.scroll_overview_strip(
|
||||||
pointer.current_location(),
|
pointer.current_location(),
|
||||||
if horizontal != 0.0 { horizontal } else { vertical },
|
if horizontal != 0.0 { horizontal } else { vertical },
|
||||||
|
|||||||
@@ -576,6 +576,16 @@ impl Ferese {
|
|||||||
Some(buffer)
|
Some(buffer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn indicator_label(
|
||||||
|
&mut self,
|
||||||
|
text: &str,
|
||||||
|
scale: f64,
|
||||||
|
color: [f32; 4],
|
||||||
|
size: f64,
|
||||||
|
) -> Option<LabelBuffer> {
|
||||||
|
self.overview.label_with_size(text, scale, color, size)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn overview_workspace_label(
|
pub(crate) fn overview_workspace_label(
|
||||||
&mut self,
|
&mut self,
|
||||||
index: u32,
|
index: u32,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ mod overview;
|
|||||||
mod resources;
|
mod resources;
|
||||||
mod scene;
|
mod scene;
|
||||||
mod shader;
|
mod shader;
|
||||||
|
mod tab_indicator;
|
||||||
mod tabs;
|
mod tabs;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -332,6 +333,10 @@ pub(crate) enum OverviewChromePart {
|
|||||||
Caption,
|
Caption,
|
||||||
Tab,
|
Tab,
|
||||||
TabActive,
|
TabActive,
|
||||||
|
TabRail,
|
||||||
|
TabList,
|
||||||
|
TabRow,
|
||||||
|
TabFlash,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
@@ -122,9 +122,13 @@ pub(super) fn overview_strip_elements(
|
|||||||
&& let Some(cache) = state.render.outputs.get_mut(&output_id)
|
&& let Some(cache) = state.render.outputs.get_mut(&output_id)
|
||||||
{
|
{
|
||||||
cache.chrome.retain(|(id, part), _| match part {
|
cache.chrome.retain(|(id, part), _| match part {
|
||||||
OverviewChromePart::Caption | OverviewChromePart::Tab | OverviewChromePart::TabActive => {
|
OverviewChromePart::Caption
|
||||||
state.windows.window(ferese_layout::WindowId(*id)).is_some()
|
| OverviewChromePart::Tab
|
||||||
}
|
| OverviewChromePart::TabActive
|
||||||
|
| OverviewChromePart::TabRail
|
||||||
|
| OverviewChromePart::TabList
|
||||||
|
| OverviewChromePart::TabRow
|
||||||
|
| OverviewChromePart::TabFlash => state.windows.window(ferese_layout::WindowId(*id)).is_some(),
|
||||||
OverviewChromePart::Card | OverviewChromePart::Outline => card_ids.contains(id),
|
OverviewChromePart::Card | OverviewChromePart::Outline => card_ids.contains(id),
|
||||||
OverviewChromePart::Strip => true,
|
OverviewChromePart::Strip => true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,7 +71,16 @@ impl RenderResources {
|
|||||||
self.windows.remove(&id);
|
self.windows.remove(&id);
|
||||||
for output in self.outputs.values_mut() {
|
for output in self.outputs.values_mut() {
|
||||||
output.chrome.retain(|(key, part), _| {
|
output.chrome.retain(|(key, part), _| {
|
||||||
*key != id.0 || !matches!(part, OverviewChromePart::Tab | OverviewChromePart::TabActive)
|
*key != id.0
|
||||||
|
|| !matches!(
|
||||||
|
part,
|
||||||
|
OverviewChromePart::Tab
|
||||||
|
| OverviewChromePart::TabActive
|
||||||
|
| OverviewChromePart::TabRail
|
||||||
|
| OverviewChromePart::TabList
|
||||||
|
| OverviewChromePart::TabRow
|
||||||
|
| OverviewChromePart::TabFlash
|
||||||
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
use super::*;
|
||||||
|
use ferese_layout::{Rect, TabBar, WindowId};
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn text_element(
|
||||||
|
state: &mut Ferese,
|
||||||
|
renderer: &mut GlesRenderer,
|
||||||
|
output_origin: Point<i32, Logical>,
|
||||||
|
scale: f64,
|
||||||
|
rect: Rect,
|
||||||
|
text: &str,
|
||||||
|
alpha: f32,
|
||||||
|
centered: bool,
|
||||||
|
) -> Option<AnimatedWindowRenderElement> {
|
||||||
|
let settings = state.tab_indicator_settings;
|
||||||
|
let color = settings.text_color.unwrap_or(state.theme_settings.text_primary_color.0);
|
||||||
|
let limit = ((rect.width - 16.0) / (settings.font_size * 0.65)).floor().max(1.0) as usize;
|
||||||
|
let mut chars = text.chars().filter(|c| !c.is_control());
|
||||||
|
let mut title = chars.by_ref().take(limit).collect::<String>();
|
||||||
|
if chars.next().is_some() {
|
||||||
|
title.pop();
|
||||||
|
title.push('…');
|
||||||
|
}
|
||||||
|
let (buffer, size) = state.indicator_label(&title, scale, color, settings.font_size)?;
|
||||||
|
let x = if centered {
|
||||||
|
((rect.width - f64::from(size.w) / scale) * 0.5).max(8.0)
|
||||||
|
} else {
|
||||||
|
8.0
|
||||||
|
};
|
||||||
|
let location = Point::<i32, Physical>::from((
|
||||||
|
((rect.x - f64::from(output_origin.x) + x) * scale).round() as i32,
|
||||||
|
((rect.y - f64::from(output_origin.y) + rect.height * 0.5) * scale).round() as i32 - size.h / 2,
|
||||||
|
));
|
||||||
|
let element = MemoryRenderBufferRenderElement::from_buffer(
|
||||||
|
renderer,
|
||||||
|
location.to_f64(),
|
||||||
|
&buffer,
|
||||||
|
Some(alpha),
|
||||||
|
Some(Rectangle::from_size((f64::from(size.w), f64::from(size.h)).into())),
|
||||||
|
Some(
|
||||||
|
(
|
||||||
|
(f64::from(size.w) / scale).round() as i32,
|
||||||
|
(f64::from(size.h) / scale).round() as i32,
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
RenderElementKind::Unspecified,
|
||||||
|
)
|
||||||
|
.ok()?;
|
||||||
|
let element = RescaleRenderElement::from_element(element, location, 1.0);
|
||||||
|
let element = RelocateRenderElement::from_element(element, (0, 0), Relocate::Relative);
|
||||||
|
let clip = Rect::new(rect.x + 8.0, rect.y, (rect.width - 16.0).max(0.0), rect.height);
|
||||||
|
CropRenderElement::from_element(element, scale, physical_rect(clip, output_origin, scale)).map(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(state: &Ferese, id: WindowId, capture: bool) -> String {
|
||||||
|
if capture && state.windows.window(id).is_none_or(|w| state.capture_protected(w)) {
|
||||||
|
return "Protected window".into();
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.windows
|
||||||
|
.record(id)
|
||||||
|
.map(|r| {
|
||||||
|
if !r.title.trim().is_empty() {
|
||||||
|
r.title.clone()
|
||||||
|
} else if !r.app_id.is_empty() {
|
||||||
|
r.app_id.clone()
|
||||||
|
} else {
|
||||||
|
"Window".into()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn elements(
|
||||||
|
state: &mut Ferese,
|
||||||
|
renderer: &mut GlesRenderer,
|
||||||
|
output: &Output,
|
||||||
|
bar: &TabBar,
|
||||||
|
content: Rect,
|
||||||
|
alpha: f32,
|
||||||
|
capture: bool,
|
||||||
|
) -> Vec<AnimatedWindowRenderElement> {
|
||||||
|
let Some(g) = state.tab_indicator_geometry(bar, content) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Some(output_geometry) = state.space.output_geometry(output) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let scale = output.current_scale().fractional_scale();
|
||||||
|
let settings = state.tab_indicator_settings;
|
||||||
|
let anchor = bar.group.windows[0];
|
||||||
|
let accent = settings.active_color.unwrap_or(state.theme_settings.accent_color.0);
|
||||||
|
let inactive = settings.inactive_color.unwrap_or(state.theme_settings.border_color.0);
|
||||||
|
let background = settings
|
||||||
|
.background_color
|
||||||
|
.unwrap_or(state.theme_settings.surface_base_color.0);
|
||||||
|
let radius = state.theme_settings.material_radius;
|
||||||
|
let hover = state
|
||||||
|
.tab_indicator
|
||||||
|
.hover
|
||||||
|
.as_ref()
|
||||||
|
.filter(|h| h.anchor == anchor)
|
||||||
|
.map_or(0.0, |h| h.opacity.current.clamp(0.0, 1.0) as f32);
|
||||||
|
let flash = state
|
||||||
|
.tab_indicator
|
||||||
|
.flash
|
||||||
|
.as_ref()
|
||||||
|
.filter(|h| h.anchor == anchor)
|
||||||
|
.map_or(0.0, |h| h.opacity.current.clamp(0.0, 1.0) as f32);
|
||||||
|
let mut elements = Vec::new();
|
||||||
|
state.retain_overview_labels();
|
||||||
|
if hover > 0.0 {
|
||||||
|
let first = state
|
||||||
|
.tab_indicator
|
||||||
|
.first_row
|
||||||
|
.min(bar.group.windows.len().saturating_sub(g.rows));
|
||||||
|
for (row, id) in bar.group.windows.iter().copied().skip(first).take(g.rows).enumerate() {
|
||||||
|
let rect = g.row(row);
|
||||||
|
let label = title(state, id, capture);
|
||||||
|
if let Some(element) = text_element(
|
||||||
|
state,
|
||||||
|
renderer,
|
||||||
|
output_geometry.loc,
|
||||||
|
scale,
|
||||||
|
rect,
|
||||||
|
&label,
|
||||||
|
alpha * hover,
|
||||||
|
false,
|
||||||
|
) {
|
||||||
|
elements.push(element);
|
||||||
|
}
|
||||||
|
if id == bar.group.active {
|
||||||
|
let mut fill = background;
|
||||||
|
for i in 0..3 {
|
||||||
|
fill[i] += (accent[i] - fill[i]) * 0.2;
|
||||||
|
}
|
||||||
|
fill[3] = alpha * hover;
|
||||||
|
if let Some(element) = overview_chrome_element(
|
||||||
|
state,
|
||||||
|
renderer,
|
||||||
|
output,
|
||||||
|
output_geometry,
|
||||||
|
scale,
|
||||||
|
(id.0, OverviewChromePart::TabRow),
|
||||||
|
rect,
|
||||||
|
fill,
|
||||||
|
0.0,
|
||||||
|
(radius - g.padding).max(0.0).min(rect.height * 0.5),
|
||||||
|
) {
|
||||||
|
elements.push(element.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(element) = overview_chrome_element(
|
||||||
|
state,
|
||||||
|
renderer,
|
||||||
|
output,
|
||||||
|
output_geometry,
|
||||||
|
scale,
|
||||||
|
(anchor.0, OverviewChromePart::TabList),
|
||||||
|
g.list,
|
||||||
|
color_with_alpha(background, alpha * hover),
|
||||||
|
0.0,
|
||||||
|
radius.min(g.list.height * 0.5),
|
||||||
|
) {
|
||||||
|
elements.push(element.into());
|
||||||
|
}
|
||||||
|
} else if flash > 0.0 {
|
||||||
|
let index = bar
|
||||||
|
.group
|
||||||
|
.windows
|
||||||
|
.iter()
|
||||||
|
.position(|id| *id == bar.group.active)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let label = format!(
|
||||||
|
"{}/{} · {}",
|
||||||
|
index + 1,
|
||||||
|
bar.group.windows.len(),
|
||||||
|
title(state, bar.group.active, capture)
|
||||||
|
);
|
||||||
|
let width = (label.chars().count() as f64 * settings.font_size * 0.65 + 24.0).min(g.list.width);
|
||||||
|
let bounds = Rect::new(
|
||||||
|
output_geometry.loc.x as f64,
|
||||||
|
output_geometry.loc.y as f64,
|
||||||
|
output_geometry.size.w as f64,
|
||||||
|
output_geometry.size.h as f64,
|
||||||
|
);
|
||||||
|
let rect = Rect::new(
|
||||||
|
(content.x + (content.width - width) * 0.5).clamp(bounds.x, bounds.x + bounds.width - width),
|
||||||
|
(content.y + settings.padding).clamp(bounds.y, bounds.y + bounds.height - g.row_height),
|
||||||
|
width,
|
||||||
|
g.row_height,
|
||||||
|
);
|
||||||
|
if let Some(element) = text_element(
|
||||||
|
state,
|
||||||
|
renderer,
|
||||||
|
output_geometry.loc,
|
||||||
|
scale,
|
||||||
|
rect,
|
||||||
|
&label,
|
||||||
|
alpha * flash,
|
||||||
|
true,
|
||||||
|
) {
|
||||||
|
elements.push(element);
|
||||||
|
}
|
||||||
|
if let Some(element) = overview_chrome_element(
|
||||||
|
state,
|
||||||
|
renderer,
|
||||||
|
output,
|
||||||
|
output_geometry,
|
||||||
|
scale,
|
||||||
|
(anchor.0, OverviewChromePart::TabFlash),
|
||||||
|
rect,
|
||||||
|
color_with_alpha(background, alpha * flash),
|
||||||
|
0.0,
|
||||||
|
rect.height * 0.5,
|
||||||
|
) {
|
||||||
|
elements.push(element.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (index, id) in bar.group.windows.iter().copied().enumerate() {
|
||||||
|
let rect = Rect::new(
|
||||||
|
g.rail.x,
|
||||||
|
g.rail.y + index as f64 * g.pitch,
|
||||||
|
g.rail.width,
|
||||||
|
g.segment_height,
|
||||||
|
);
|
||||||
|
let color = if id == bar.group.active { accent } else { inactive };
|
||||||
|
if let Some(element) = overview_chrome_element(
|
||||||
|
state,
|
||||||
|
renderer,
|
||||||
|
output,
|
||||||
|
output_geometry,
|
||||||
|
scale,
|
||||||
|
(id.0, OverviewChromePart::TabRail),
|
||||||
|
rect,
|
||||||
|
color_with_alpha(color, alpha),
|
||||||
|
0.0,
|
||||||
|
rect.width.min(rect.height) * 0.5,
|
||||||
|
) {
|
||||||
|
elements.push(element.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elements
|
||||||
|
}
|
||||||
@@ -24,6 +24,20 @@ pub(super) fn tab_elements(
|
|||||||
let Some(sample) = frame.windows.get(&active) else {
|
let Some(sample) = frame.windows.get(&active) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
|
if matches!(
|
||||||
|
state.workspaces.workspace(workspace).map(|w| &w.layout),
|
||||||
|
Some(ferese_core::WorkspaceLayout::Scrolling(_))
|
||||||
|
) {
|
||||||
|
return super::tab_indicator::elements(
|
||||||
|
state,
|
||||||
|
renderer,
|
||||||
|
output,
|
||||||
|
&bar,
|
||||||
|
sample.presentation.bounds.current,
|
||||||
|
sample.presentation.alpha(),
|
||||||
|
capture,
|
||||||
|
);
|
||||||
|
}
|
||||||
if bar.rect.height <= 0.0 {
|
if bar.rect.height <= 0.0 {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ mod layout;
|
|||||||
mod navigation;
|
mod navigation;
|
||||||
mod outputs;
|
mod outputs;
|
||||||
mod reconciliation;
|
mod reconciliation;
|
||||||
|
pub(crate) mod tab_indicator;
|
||||||
mod tabs;
|
mod tabs;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod window_lifecycle_tests;
|
mod window_lifecycle_tests;
|
||||||
@@ -456,6 +457,8 @@ pub struct Ferese {
|
|||||||
workspace_slides: HashMap<OutputId, WorkspaceSlide>,
|
workspace_slides: HashMap<OutputId, WorkspaceSlide>,
|
||||||
workspace_slide_offsets: HashMap<WorkspaceId, (f64, f64)>,
|
workspace_slide_offsets: HashMap<WorkspaceId, (f64, f64)>,
|
||||||
pub focused_window: Option<WindowId>,
|
pub focused_window: Option<WindowId>,
|
||||||
|
pub(crate) tab_indicator: tab_indicator::TabIndicatorState,
|
||||||
|
pub(crate) tab_indicator_settings: crate::config::TabIndicatorSettings,
|
||||||
pub(crate) tab_bars: HashMap<WorkspaceId, Vec<ferese_layout::TabBar>>,
|
pub(crate) tab_bars: HashMap<WorkspaceId, Vec<ferese_layout::TabBar>>,
|
||||||
pub(crate) tab_buttons: HashSet<u32>,
|
pub(crate) tab_buttons: HashSet<u32>,
|
||||||
pub(crate) focus_history: ferese_core::FocusHistory,
|
pub(crate) focus_history: ferese_core::FocusHistory,
|
||||||
@@ -576,6 +579,7 @@ pub struct RuntimeConfig {
|
|||||||
pub focus_effect: crate::config::FocusEffectSettings,
|
pub focus_effect: crate::config::FocusEffectSettings,
|
||||||
pub default_column_width: ColumnWidth,
|
pub default_column_width: ColumnWidth,
|
||||||
pub default_column_display: ferese_layout::ColumnDisplay,
|
pub default_column_display: ferese_layout::ColumnDisplay,
|
||||||
|
pub(crate) tab_indicator: crate::config::TabIndicatorSettings,
|
||||||
pub scrolling_focus_strategy: ViewportFocusStrategy,
|
pub scrolling_focus_strategy: ViewportFocusStrategy,
|
||||||
pub column_width_presets: Vec<ColumnWidth>,
|
pub column_width_presets: Vec<ColumnWidth>,
|
||||||
pub animations_enabled: bool,
|
pub animations_enabled: bool,
|
||||||
@@ -752,6 +756,8 @@ impl Ferese {
|
|||||||
workspace_slides: HashMap::new(),
|
workspace_slides: HashMap::new(),
|
||||||
workspace_slide_offsets: HashMap::new(),
|
workspace_slide_offsets: HashMap::new(),
|
||||||
focused_window: None,
|
focused_window: None,
|
||||||
|
tab_indicator: tab_indicator::TabIndicatorState::default(),
|
||||||
|
tab_indicator_settings: config.tab_indicator,
|
||||||
tab_bars: HashMap::new(),
|
tab_bars: HashMap::new(),
|
||||||
tab_buttons: HashSet::new(),
|
tab_buttons: HashSet::new(),
|
||||||
focus_history: Default::default(),
|
focus_history: Default::default(),
|
||||||
@@ -980,6 +986,8 @@ impl Ferese {
|
|||||||
self.idle_inhibit = config.idle_inhibit;
|
self.idle_inhibit = config.idle_inhibit;
|
||||||
self.theme_settings = config.theme_settings;
|
self.theme_settings = config.theme_settings;
|
||||||
self.panel_corner_radius = config.panel_corner_radius;
|
self.panel_corner_radius = config.panel_corner_radius;
|
||||||
|
self.tab_indicator_settings = config.tab_indicator;
|
||||||
|
self.clear_tab_indicator();
|
||||||
self.panel_background_opacity = config.panel_background_opacity;
|
self.panel_background_opacity = config.panel_background_opacity;
|
||||||
self.panel_style = config.panel_style;
|
self.panel_style = config.panel_style;
|
||||||
self.focus_effect = config.focus_effect;
|
self.focus_effect = config.focus_effect;
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ impl Ferese {
|
|||||||
|| !self.dismissing_popups.is_empty()
|
|| !self.dismissing_popups.is_empty()
|
||||||
|| self.render.snapshots().next().is_some()
|
|| self.render.snapshots().next().is_some()
|
||||||
|| self.overview.needs_tick()
|
|| self.overview.needs_tick()
|
||||||
|
|| self.tab_indicator.is_animating()
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -160,7 +161,9 @@ impl Ferese {
|
|||||||
self.release_presentation_dependencies();
|
self.release_presentation_dependencies();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut active_animation = false;
|
let mut active_animation = self
|
||||||
|
.tab_indicator
|
||||||
|
.advance(delta, self.spring_config, self.animations_enabled);
|
||||||
let mut completed_slides = Vec::new();
|
let mut completed_slides = Vec::new();
|
||||||
for (output, slide) in &mut self.workspace_slides {
|
for (output, slide) in &mut self.workspace_slides {
|
||||||
if slide.held_progress.is_some() {
|
if slide.held_progress.is_some() {
|
||||||
|
|||||||
@@ -640,6 +640,7 @@ impl Ferese {
|
|||||||
self.rebuild_presentation_dependencies();
|
self.rebuild_presentation_dependencies();
|
||||||
self.sync_window_stacking();
|
self.sync_window_stacking();
|
||||||
self.retarget_overview();
|
self.retarget_overview();
|
||||||
|
self.reconcile_tab_indicator();
|
||||||
if !finalize {
|
if !finalize {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.relayout();
|
self.relayout();
|
||||||
|
self.flash_tab_switch(current, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn move_direction(&mut self, direction: Direction) {
|
pub fn move_direction(&mut self, direction: Direction) {
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ impl Ferese {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|window| Some(window.output) == self.output_id(output))
|
.any(|window| Some(window.output) == self.output_id(output))
|
||||||
|| self.overview.is_animating(self.spring_config)
|
|| self.overview.is_animating(self.spring_config)
|
||||||
|
|| self.tab_indicator_output_animating(output)
|
||||||
|| self
|
|| self
|
||||||
.dismissing_popups
|
.dismissing_popups
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -0,0 +1,558 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::config::TabIndicatorSettings;
|
||||||
|
use smithay::reexports::calloop::{
|
||||||
|
RegistrationToken,
|
||||||
|
timer::{TimeoutAction, Timer},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct Overlay {
|
||||||
|
pub anchor: WindowId,
|
||||||
|
pub opacity: AnimatedValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(crate) struct TabIndicatorState {
|
||||||
|
pub hover: Option<Overlay>,
|
||||||
|
pub flash: Option<Overlay>,
|
||||||
|
pub first_row: usize,
|
||||||
|
timer: Option<RegistrationToken>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TabIndicatorState {
|
||||||
|
pub fn is_animating(&self) -> bool {
|
||||||
|
[&self.hover, &self.flash]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.any(|o| o.opacity.is_animating())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn advance(&mut self, delta: Duration, spring: SpringConfig, enabled: bool) -> bool {
|
||||||
|
let mut moving = false;
|
||||||
|
for overlay in [&mut self.hover, &mut self.flash] {
|
||||||
|
if let Some(value) = overlay {
|
||||||
|
if enabled {
|
||||||
|
moving |= value
|
||||||
|
.opacity
|
||||||
|
.advance(delta, crate::presentation::emphasis_spring(spring));
|
||||||
|
} else {
|
||||||
|
value.opacity.snap();
|
||||||
|
}
|
||||||
|
if !value.opacity.is_animating() && value.opacity.current == 0.0 {
|
||||||
|
*overlay = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moving
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub(crate) struct IndicatorGeometry {
|
||||||
|
pub rail: Rect,
|
||||||
|
pub hit: Rect,
|
||||||
|
pub list: Rect,
|
||||||
|
pub segment_height: f64,
|
||||||
|
pub pitch: f64,
|
||||||
|
pub row_height: f64,
|
||||||
|
pub padding: f64,
|
||||||
|
pub rows: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn contains(rect: Rect, p: Point<f64, Logical>) -> bool {
|
||||||
|
p.x >= rect.x && p.x < rect.x + rect.width && p.y >= rect.y && p.y < rect.y + rect.height
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IndicatorGeometry {
|
||||||
|
pub fn new(content: Rect, output: Rect, left_boundary: f64, count: usize, c: TabIndicatorSettings) -> Option<Self> {
|
||||||
|
if count < 2 || content.width <= 0.0 || content.height <= 0.0 || output.width <= 0.0 || output.height <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let left = content.x.max(output.x);
|
||||||
|
let right = (content.x + content.width).min(output.x + output.width);
|
||||||
|
let top = content.y.max(output.y);
|
||||||
|
let bottom = (content.y + content.height).min(output.y + output.height);
|
||||||
|
if right - left < c.width || bottom <= top {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let gap = (content.x - left_boundary).max(0.0);
|
||||||
|
let x = if content.x >= output.x && gap >= c.gap_threshold {
|
||||||
|
content.x - (c.gap_threshold + c.width) * 0.5
|
||||||
|
} else {
|
||||||
|
left
|
||||||
|
}
|
||||||
|
.clamp(output.x, (right - c.width).max(output.x));
|
||||||
|
let natural = count as f64 * c.segment_height + count.saturating_sub(1) as f64 * c.segment_gap;
|
||||||
|
let fit = ((bottom - top) / natural).min(1.0);
|
||||||
|
let height = (natural * fit).min(bottom - top);
|
||||||
|
let rail = Rect::new(x, top + (bottom - top - height) * 0.5, c.width, height);
|
||||||
|
let hit_width = c.hit_width.min(output.width);
|
||||||
|
let hit = Rect::new(
|
||||||
|
(x + c.width * 0.5 - hit_width * 0.5).clamp(output.x, output.x + output.width - hit_width),
|
||||||
|
rail.y,
|
||||||
|
hit_width,
|
||||||
|
rail.height,
|
||||||
|
);
|
||||||
|
let padding = c.padding.min(output.height * 0.1).min(output.width * 0.1);
|
||||||
|
let row_height = c.row_height.min((output.height - 2.0 * padding).max(f64::EPSILON));
|
||||||
|
let rows = (((output.height - 2.0 * padding) / row_height).floor() as usize)
|
||||||
|
.max(1)
|
||||||
|
.min(count);
|
||||||
|
let list_width = c.list_width.min(output.width);
|
||||||
|
let list_height = (rows as f64 * row_height + 2.0 * padding).min(output.height);
|
||||||
|
let x = if hit.x + hit.width + padding + list_width <= output.x + output.width {
|
||||||
|
hit.x + hit.width + padding
|
||||||
|
} else if hit.x - padding - list_width >= output.x {
|
||||||
|
hit.x - padding - list_width
|
||||||
|
} else {
|
||||||
|
(hit.x + hit.width + padding).clamp(output.x, output.x + output.width - list_width)
|
||||||
|
};
|
||||||
|
let list = Rect::new(
|
||||||
|
x,
|
||||||
|
(rail.y + rail.height * 0.5 - list_height * 0.5).clamp(output.y, output.y + output.height - list_height),
|
||||||
|
list_width,
|
||||||
|
list_height,
|
||||||
|
);
|
||||||
|
Some(Self {
|
||||||
|
rail,
|
||||||
|
hit,
|
||||||
|
list,
|
||||||
|
segment_height: c.segment_height * fit,
|
||||||
|
pitch: (c.segment_height + c.segment_gap) * fit,
|
||||||
|
row_height,
|
||||||
|
padding,
|
||||||
|
rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn row(&self, visible_index: usize) -> Rect {
|
||||||
|
Rect::new(
|
||||||
|
self.list.x + self.padding,
|
||||||
|
self.list.y + self.padding + visible_index as f64 * self.row_height,
|
||||||
|
(self.list.width - 2.0 * self.padding).max(0.0),
|
||||||
|
self.row_height,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hover_contains(&self, p: Point<f64, Logical>) -> bool {
|
||||||
|
let left = self.hit.x.min(self.list.x);
|
||||||
|
let top = self.hit.y.min(self.list.y);
|
||||||
|
contains(
|
||||||
|
Rect::new(
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
(self.hit.x + self.hit.width).max(self.list.x + self.list.width) - left,
|
||||||
|
(self.hit.y + self.hit.height).max(self.list.y + self.list.height) - top,
|
||||||
|
),
|
||||||
|
p,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ferese {
|
||||||
|
pub(crate) fn scrolling_tab_bar(&self, anchor: WindowId) -> Option<&ferese_layout::TabBar> {
|
||||||
|
let workspace = self.workspaces.workspace_for_window(anchor)?;
|
||||||
|
if !matches!(
|
||||||
|
self.workspaces.workspace(workspace)?.layout,
|
||||||
|
WorkspaceLayout::Scrolling(_)
|
||||||
|
) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.visible_tab_bars(workspace)
|
||||||
|
.find(|bar| bar.group.windows.first() == Some(&anchor))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn tab_indicator_geometry(
|
||||||
|
&self,
|
||||||
|
bar: &ferese_layout::TabBar,
|
||||||
|
content: Rect,
|
||||||
|
) -> Option<IndicatorGeometry> {
|
||||||
|
let workspace = self.workspaces.workspace_for_window(bar.group.active)?;
|
||||||
|
let output_id = self.output_workspaces.output_for_workspace(workspace)?;
|
||||||
|
if self.output_workspaces.active_workspace(output_id) != Some(workspace) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let output = self.outputs_by_id.get(&output_id)?;
|
||||||
|
let g = self.space.output_geometry(output)?;
|
||||||
|
let bounds = Rect::new(g.loc.x as f64, g.loc.y as f64, g.size.w as f64, g.size.h as f64);
|
||||||
|
let mut boundary = bounds.x;
|
||||||
|
// Measure the current visible gap, including animated column positions.
|
||||||
|
for window in self.space.elements() {
|
||||||
|
let Some(id) = self.windows.ids().get(window).copied() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if id == bar.group.active
|
||||||
|
|| self.workspaces.workspace_for_window(id) != Some(workspace)
|
||||||
|
|| !self.workspaces.workspace(workspace)?.layout.contains(id)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(rect) = self.presented_window_rect(id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if rect.x + rect.width <= content.x
|
||||||
|
&& rect.y < content.y + content.height
|
||||||
|
&& rect.y + rect.height > content.y
|
||||||
|
{
|
||||||
|
boundary = boundary.max(rect.x + rect.width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IndicatorGeometry::new(
|
||||||
|
content,
|
||||||
|
bounds,
|
||||||
|
boundary,
|
||||||
|
bar.group.windows.len(),
|
||||||
|
self.tab_indicator_settings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tab_indicator_can_hit(&self, active: WindowId, p: Point<f64, Logical>) -> bool {
|
||||||
|
if self.session_lock.active()
|
||||||
|
|| self.input_capture.active()
|
||||||
|
|| self.overview.is_presenting()
|
||||||
|
|| self.seat.get_pointer().is_some_and(|p| p.is_grabbed())
|
||||||
|
|| self.layer_surface_under(p, &[Layer::Overlay, Layer::Top]).is_some()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Match the renderer's per-window stacking. An overlay can cover its
|
||||||
|
// own client and lower windows, never floating windows/popups above it.
|
||||||
|
for window in self.space.elements().rev() {
|
||||||
|
let Some(id) = self.windows.ids().get(window).copied() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if id == active {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if self.presented_window_rect(id).is_some_and(|r| contains(r, p)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.window_under_visual(p)
|
||||||
|
.is_some_and(|w| self.windows.ids().get(&w) == Some(&active))
|
||||||
|
&& let Some((mut surface, _)) = self.window_surface_under(p)
|
||||||
|
{
|
||||||
|
while let Some(parent) = smithay::wayland::compositor::get_parent(&surface) {
|
||||||
|
surface = parent;
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.windows
|
||||||
|
.window(active)
|
||||||
|
.and_then(|w| w.toplevel())
|
||||||
|
.is_none_or(|t| t.wl_surface() != &surface)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn indicator_tab_at(&self, p: Point<f64, Logical>) -> Option<WindowId> {
|
||||||
|
let workspace = self.workspace_under_pointer(p)?;
|
||||||
|
if !matches!(
|
||||||
|
self.workspaces.workspace(workspace)?.layout,
|
||||||
|
WorkspaceLayout::Scrolling(_)
|
||||||
|
) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for bar in self.visible_tab_bars(workspace) {
|
||||||
|
let Some(content) = self.presented_window_rect(bar.group.active) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(g) = self.tab_indicator_geometry(bar, content) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !self.tab_indicator_can_hit(bar.group.active, p) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let anchor = bar.group.windows[0];
|
||||||
|
let expanded = self
|
||||||
|
.tab_indicator
|
||||||
|
.hover
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|h| h.anchor == anchor && h.opacity.target > 0.0);
|
||||||
|
if expanded && contains(g.list, p) {
|
||||||
|
let first = self
|
||||||
|
.tab_indicator
|
||||||
|
.first_row
|
||||||
|
.min(bar.group.windows.len().saturating_sub(g.rows));
|
||||||
|
let index = ((p.y - g.list.y - g.padding).max(0.0) / g.row_height).floor() as usize;
|
||||||
|
return bar.group.windows.get(first + index.min(g.rows - 1)).copied();
|
||||||
|
}
|
||||||
|
if contains(g.hit, p) {
|
||||||
|
let index = ((p.y - g.rail.y) / g.pitch).floor() as usize;
|
||||||
|
return bar.group.windows.get(index.min(bar.group.windows.len() - 1)).copied();
|
||||||
|
}
|
||||||
|
if expanded && g.hover_contains(p) {
|
||||||
|
return Some(bar.group.active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn update_tab_indicator_hover(&mut self, p: Point<f64, Logical>) {
|
||||||
|
let anchor = self.indicator_tab_at(p).and_then(|id| {
|
||||||
|
self.workspaces
|
||||||
|
.workspace_for_window(id)
|
||||||
|
.and_then(|ws| self.workspaces.workspace(ws))
|
||||||
|
.and_then(|ws| ws.layout.tab_group(id))
|
||||||
|
.and_then(|group| group.windows.first().copied())
|
||||||
|
});
|
||||||
|
let unchanged = match (anchor, &self.tab_indicator.hover) {
|
||||||
|
(Some(anchor), Some(hover)) => hover.anchor == anchor && hover.opacity.target == 1.0,
|
||||||
|
(None, Some(hover)) => hover.opacity.target == 0.0,
|
||||||
|
(None, None) => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if unchanged {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.advance_animations(Instant::now());
|
||||||
|
if let Some(anchor) = anchor {
|
||||||
|
let same = self.tab_indicator.hover.as_ref().is_some_and(|h| h.anchor == anchor);
|
||||||
|
if !same {
|
||||||
|
let selected = self
|
||||||
|
.scrolling_tab_bar(anchor)
|
||||||
|
.and_then(|bar| {
|
||||||
|
let g = self.tab_indicator_geometry(bar, self.presented_window_rect(bar.group.active)?)?;
|
||||||
|
let selected = bar.group.windows.iter().position(|id| *id == bar.group.active)?;
|
||||||
|
Some(
|
||||||
|
selected
|
||||||
|
.saturating_sub(g.rows / 2)
|
||||||
|
.min(bar.group.windows.len().saturating_sub(g.rows)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
self.tab_indicator.first_row = selected;
|
||||||
|
self.tab_indicator.hover = Some(Overlay {
|
||||||
|
anchor,
|
||||||
|
opacity: AnimatedValue::new(0.0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(hover) = &mut self.tab_indicator.hover {
|
||||||
|
if hover.opacity.target == 1.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hover.opacity.retarget_preserving_motion(1.0);
|
||||||
|
if !self.animations_enabled {
|
||||||
|
hover.opacity.snap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(hover) = &mut self.tab_indicator.hover {
|
||||||
|
if hover.opacity.target == 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hover.opacity.retarget_preserving_motion(0.0);
|
||||||
|
if !self.animations_enabled {
|
||||||
|
hover.opacity.snap();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
crate::backends::direct::render_all(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn scroll_tab_indicator(&mut self, p: Point<f64, Logical>, amount: f64) -> bool {
|
||||||
|
let Some(hover) = &self.tab_indicator.hover else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if hover.opacity.target == 0.0 || self.indicator_tab_at(p).is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(bar) = self.scrolling_tab_bar(hover.anchor) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(content) = self.presented_window_rect(bar.group.active) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(g) = self.tab_indicator_geometry(bar, content) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let max = bar.group.windows.len().saturating_sub(g.rows);
|
||||||
|
if amount != 0.0 {
|
||||||
|
self.tab_indicator.first_row = if amount > 0.0 {
|
||||||
|
self.tab_indicator.first_row.saturating_add(1).min(max)
|
||||||
|
} else {
|
||||||
|
self.tab_indicator.first_row.saturating_sub(1).min(max)
|
||||||
|
};
|
||||||
|
crate::backends::direct::render_all(self);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn flash_tab_switch(&mut self, previous: WindowId, current: WindowId) {
|
||||||
|
if previous == current {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(workspace) = self
|
||||||
|
.workspaces
|
||||||
|
.workspace_for_window(current)
|
||||||
|
.and_then(|ws| self.workspaces.workspace(ws))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let WorkspaceLayout::Scrolling(layout) = &workspace.layout else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(group) = layout.tab_group(current).filter(|g| g.windows.contains(&previous)) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if self.tab_indicator_settings.flash_duration.is_zero() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let anchor = group.windows[0];
|
||||||
|
if let Some(token) = self.tab_indicator.timer.take() {
|
||||||
|
self.loop_handle.remove(token);
|
||||||
|
}
|
||||||
|
if let Some(flash) = self.tab_indicator.flash.as_mut().filter(|f| f.anchor == anchor) {
|
||||||
|
flash.opacity.retarget_preserving_motion(1.0);
|
||||||
|
} else {
|
||||||
|
let mut opacity = AnimatedValue::new(0.0);
|
||||||
|
opacity.set_target(1.0);
|
||||||
|
self.tab_indicator.flash = Some(Overlay { anchor, opacity });
|
||||||
|
}
|
||||||
|
if !self.animations_enabled
|
||||||
|
&& let Some(flash) = &mut self.tab_indicator.flash
|
||||||
|
{
|
||||||
|
flash.opacity.snap();
|
||||||
|
}
|
||||||
|
match self.loop_handle.insert_source(
|
||||||
|
Timer::from_duration(self.tab_indicator_settings.flash_duration),
|
||||||
|
|_, _, state| {
|
||||||
|
state.tab_indicator.timer = None;
|
||||||
|
if let Some(flash) = &mut state.tab_indicator.flash {
|
||||||
|
flash.opacity.set_target(0.0);
|
||||||
|
if !state.animations_enabled {
|
||||||
|
flash.opacity.snap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::backends::direct::render_all(state);
|
||||||
|
TimeoutAction::Drop
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Ok(token) => self.tab_indicator.timer = Some(token),
|
||||||
|
Err(error) => {
|
||||||
|
self.tab_indicator.flash = None;
|
||||||
|
tracing::warn!(%error, "could not schedule tab feedback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::backends::direct::render_all(self);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn reconcile_tab_indicator(&mut self) {
|
||||||
|
if self.session_lock.active() || self.input_capture.active() || self.overview.is_presenting() {
|
||||||
|
self.clear_tab_indicator();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.tab_indicator
|
||||||
|
.hover
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|h| self.scrolling_tab_bar(h.anchor).is_none())
|
||||||
|
{
|
||||||
|
self.tab_indicator.hover = None;
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.tab_indicator
|
||||||
|
.flash
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|h| self.scrolling_tab_bar(h.anchor).is_none())
|
||||||
|
{
|
||||||
|
self.tab_indicator.flash = None;
|
||||||
|
if let Some(token) = self.tab_indicator.timer.take() {
|
||||||
|
self.loop_handle.remove(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clear_tab_indicator(&mut self) {
|
||||||
|
if let Some(token) = self.tab_indicator.timer.take() {
|
||||||
|
self.loop_handle.remove(token);
|
||||||
|
}
|
||||||
|
self.tab_indicator = TabIndicatorState::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn tab_indicator_output_animating(&self, output: &Output) -> bool {
|
||||||
|
[&self.tab_indicator.hover, &self.tab_indicator.flash]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.any(|o| o.opacity.is_animating() && self.window_belongs_to_output(o.anchor, output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn settings() -> TabIndicatorSettings {
|
||||||
|
crate::config::Config::parse_source("")
|
||||||
|
.unwrap()
|
||||||
|
.tab_indicator_settings()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rail_uses_gap_or_inner_edge_without_changing_content_geometry() {
|
||||||
|
let content = Rect::new(100.25, 20.5, 400., 300.);
|
||||||
|
let output = Rect::new(0., 0., 800., 600.);
|
||||||
|
let c = settings();
|
||||||
|
for gap in [0., 7.99, 8., 24.] {
|
||||||
|
let g = IndicatorGeometry::new(content, output, content.x - gap, 3, c).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(g.rail.width, g.rail.height, g.segment_height, g.pitch),
|
||||||
|
(4., 62., 18., 22.)
|
||||||
|
);
|
||||||
|
assert_eq!(g.rail.y, content.y + (content.height - 62.) * 0.5);
|
||||||
|
assert_eq!(g.hit.width, 12.);
|
||||||
|
if gap >= 8. {
|
||||||
|
assert!(g.rail.x >= content.x - gap && g.rail.x + g.rail.width <= content.x);
|
||||||
|
} else {
|
||||||
|
assert!(g.rail.x >= content.x && g.rail.x + g.rail.width <= content.x + 12.);
|
||||||
|
}
|
||||||
|
assert!(g.hover_contains((g.hit.x + g.hit.width, g.rail.y + 2.).into()));
|
||||||
|
}
|
||||||
|
assert!(IndicatorGeometry::new(content, output, 0., 1, c).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overflow_and_fractional_output_edges_keep_list_and_rail_reachable() {
|
||||||
|
for output in [
|
||||||
|
Rect::new(-800.25, 10.5, 800., 600.),
|
||||||
|
Rect::new(0., 0., 100., 40.),
|
||||||
|
Rect::new(0., 0., 8., 0.5),
|
||||||
|
] {
|
||||||
|
let content = Rect::new(output.x - 20., output.y, output.width + 40., output.height);
|
||||||
|
let g = IndicatorGeometry::new(content, output, output.x, 100, settings()).unwrap();
|
||||||
|
for rect in [g.rail, g.hit, g.list] {
|
||||||
|
assert!(rect.x >= output.x && rect.y >= output.y);
|
||||||
|
assert!(rect.x + rect.width <= output.x + output.width + 1e-8);
|
||||||
|
assert!(rect.y + rect.height <= output.y + output.height + 1e-8);
|
||||||
|
}
|
||||||
|
assert!(g.rows < 100 && g.rows > 0);
|
||||||
|
assert!(g.segment_height > 0.);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interrupted_hover_spring_keeps_motion_and_settles_without_background_ticks() {
|
||||||
|
let mut state = TabIndicatorState {
|
||||||
|
hover: Some(Overlay {
|
||||||
|
anchor: WindowId(1),
|
||||||
|
opacity: AnimatedValue::new(0.),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
state.hover.as_mut().unwrap().opacity.set_target(1.);
|
||||||
|
state.advance(Duration::from_millis(30), SpringConfig::default(), true);
|
||||||
|
let before = state.hover.as_ref().unwrap().opacity;
|
||||||
|
state.hover.as_mut().unwrap().opacity.retarget_preserving_motion(0.);
|
||||||
|
let after = state.hover.as_ref().unwrap().opacity;
|
||||||
|
assert_eq!((before.current, before.velocity), (after.current, after.velocity));
|
||||||
|
for _ in 0..300 {
|
||||||
|
state.advance(Duration::from_millis(16), SpringConfig::default(), true);
|
||||||
|
}
|
||||||
|
assert!(state.hover.is_none());
|
||||||
|
assert!(!state.is_animating());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,8 +71,10 @@ impl Ferese {
|
|||||||
if self.workspaces.active().fullscreen.is_some() {
|
if self.workspaces.active().fullscreen.is_some() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(target) = self.workspaces.active().layout.adjacent_tab(window, previous) {
|
if let Some(target) = self.workspaces.active().layout.adjacent_tab(window, previous)
|
||||||
self.activate_managed_window(target);
|
&& self.activate_managed_window(target)
|
||||||
|
{
|
||||||
|
self.flash_tab_switch(window, target);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +102,12 @@ impl Ferese {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let workspace = self.workspace_under_pointer(position)?;
|
let workspace = self.workspace_under_pointer(position)?;
|
||||||
|
if matches!(
|
||||||
|
self.workspaces.workspace(workspace)?.layout,
|
||||||
|
WorkspaceLayout::Scrolling(_)
|
||||||
|
) {
|
||||||
|
return self.indicator_tab_at(position);
|
||||||
|
}
|
||||||
// Floating windows and popups in front of the group keep their input.
|
// Floating windows and popups in front of the group keep their input.
|
||||||
if self.window_surface_under(position).is_some() {
|
if self.window_surface_under(position).is_some() {
|
||||||
return None;
|
return None;
|
||||||
|
|||||||
@@ -1073,3 +1073,101 @@ fn scrolling_tabs_switch_with_directional_focus_and_drop_hidden_resize_holds() {
|
|||||||
assert_eq!(state.space.elements().count(), 2);
|
assert_eq!(state.space.elements().count(), 2);
|
||||||
assert_eq!(state.tab_bars[&workspace][0].group.windows, [a, b]);
|
assert_eq!(state.tab_bars[&workspace][0].group.windows, [a, b]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_indicator_hover_switch_and_flash_preserve_window_geometry() {
|
||||||
|
if !crate::startup_tests::private_runtime(
|
||||||
|
"state::window_lifecycle_tests::tab_indicator_hover_switch_and_flash_preserve_window_geometry",
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (mut events, mut state, _output) = fixture();
|
||||||
|
let (first, _first_wire) = window(&mut state, &mut events, 0xff112233);
|
||||||
|
state.toggle_column_tabbed();
|
||||||
|
let (second, _second_wire) = window(&mut state, &mut events, 0xff445566);
|
||||||
|
state.consume_focused_window();
|
||||||
|
let a = state.windows.ids()[&first];
|
||||||
|
let b = state.windows.ids()[&second];
|
||||||
|
let workspace = state.workspaces.active_id();
|
||||||
|
let bar = state.tab_bars[&workspace][0].clone();
|
||||||
|
let before = state.windows.geometry(&b).unwrap().logical;
|
||||||
|
let g = state
|
||||||
|
.tab_indicator_geometry(&bar, state.presented_window_rect(b).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let point = (g.hit.x + g.hit.width * 0.5, g.rail.y + g.segment_height * 0.5).into();
|
||||||
|
assert_eq!(state.tab_at(point), Some(a));
|
||||||
|
assert!(
|
||||||
|
state.surface_under(point).is_none(),
|
||||||
|
"the rail must not deliver input to the client"
|
||||||
|
);
|
||||||
|
state.update_tab_indicator_hover(point);
|
||||||
|
assert_eq!(state.tab_indicator.hover.as_ref().unwrap().opacity.current, 1.);
|
||||||
|
assert_eq!(state.windows.geometry(&b).unwrap().logical, before);
|
||||||
|
let row = g.row(0);
|
||||||
|
let point = (row.x + row.width * 0.5, row.y + row.height * 0.5).into();
|
||||||
|
state.update_tab_indicator_hover(point);
|
||||||
|
assert_eq!(state.tab_at(point), Some(a));
|
||||||
|
state.activate_managed_window(a);
|
||||||
|
assert_eq!(state.windows.geometry(&a).unwrap().logical, before);
|
||||||
|
assert!(state.tab_indicator.flash.is_none(), "pointer activation does not flash");
|
||||||
|
state.update_tab_indicator_hover((799., 599.).into());
|
||||||
|
assert_eq!(state.tab_indicator.hover.as_ref().unwrap().opacity.current, 0.);
|
||||||
|
state.tab_indicator_settings.flash_duration = Duration::from_millis(1);
|
||||||
|
state.cycle_tab(false);
|
||||||
|
assert_eq!(state.focused_window, Some(b));
|
||||||
|
assert_eq!(state.tab_indicator.flash.as_ref().unwrap().opacity.current, 1.);
|
||||||
|
assert_eq!(state.windows.geometry(&b).unwrap().logical, before);
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(1);
|
||||||
|
while state
|
||||||
|
.tab_indicator
|
||||||
|
.flash
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|f| f.opacity.target != 0.)
|
||||||
|
&& Instant::now() < deadline
|
||||||
|
{
|
||||||
|
events.dispatch(Duration::from_millis(5), &mut state).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(state.tab_indicator.flash.as_ref().unwrap().opacity.current, 0.);
|
||||||
|
state.toggle_column_tabbed();
|
||||||
|
assert!(state.tab_indicator.hover.is_none());
|
||||||
|
assert!(state.tab_indicator.flash.is_none());
|
||||||
|
assert!(state.tab_at(point).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_indicator_yields_to_a_floating_window_and_disappears_with_one_tab() {
|
||||||
|
if !crate::startup_tests::private_runtime(
|
||||||
|
"state::window_lifecycle_tests::tab_indicator_yields_to_a_floating_window_and_disappears_with_one_tab",
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (mut events, mut state, _output) = fixture();
|
||||||
|
let (first, _first_wire) = window(&mut state, &mut events, 0xff112233);
|
||||||
|
state.toggle_column_tabbed();
|
||||||
|
let (second, _second_wire) = window(&mut state, &mut events, 0xff445566);
|
||||||
|
state.consume_focused_window();
|
||||||
|
let a = state.windows.ids()[&first];
|
||||||
|
let b = state.windows.ids()[&second];
|
||||||
|
let workspace = state.workspaces.active_id();
|
||||||
|
let (overlay, _overlay_wire) = window(&mut state, &mut events, 0xff778899);
|
||||||
|
let overlay_id = state.windows.ids()[&overlay];
|
||||||
|
state.toggle_focused_floating();
|
||||||
|
let bar = state.tab_bars[&workspace][0].clone();
|
||||||
|
let g = state
|
||||||
|
.tab_indicator_geometry(&bar, state.presented_window_rect(b).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
let point = (g.hit.x + 6., g.hit.y + 5.).into();
|
||||||
|
state
|
||||||
|
.workspaces
|
||||||
|
.set_floating_rect(overlay_id, Rect::new(g.hit.x, g.hit.y, 200., 100.))
|
||||||
|
.unwrap();
|
||||||
|
state.relayout();
|
||||||
|
assert!(
|
||||||
|
state.tab_at(point).is_none(),
|
||||||
|
"floating window above the rail keeps its pointer input"
|
||||||
|
);
|
||||||
|
state.activate_managed_window(b);
|
||||||
|
state.leave_tab();
|
||||||
|
assert!(state.scrolling_tab_bar(a).is_none());
|
||||||
|
assert!(state.tab_indicator.hover.is_none());
|
||||||
|
}
|
||||||
@@ -834,16 +834,11 @@ impl ScrollingLayout {
|
|||||||
let (column_x, column_width) = column_positions[column_index];
|
let (column_x, column_width) = column_positions[column_index];
|
||||||
if column.display == ColumnDisplay::Tabbed {
|
if column.display == ColumnDisplay::Tabbed {
|
||||||
let window = column.windows[column.active];
|
let window = column.windows[column.active];
|
||||||
let header = if column.windows.len() > 1 {
|
|
||||||
crate::TAB_BAR_HEIGHT.min((viewport_height - 1.0).max(0.0))
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
let mut rect = Rect::new(
|
let mut rect = Rect::new(
|
||||||
bounds.x + outer + column_x - self.viewport_x,
|
bounds.x + outer + column_x - self.viewport_x,
|
||||||
bounds.y + outer + header,
|
bounds.y + outer,
|
||||||
column_width,
|
column_width,
|
||||||
viewport_height - header,
|
viewport_height,
|
||||||
);
|
);
|
||||||
// All tabs use one size, even when their client constraints differ.
|
// All tabs use one size, even when their client constraints differ.
|
||||||
// Keep the column allocation stable and honor compatible maximums.
|
// Keep the column allocation stable and honor compatible maximums.
|
||||||
@@ -864,13 +859,13 @@ impl ScrollingLayout {
|
|||||||
let common = normalized_constraints(common);
|
let common = normalized_constraints(common);
|
||||||
record_minimum_warnings(window, rect, common, &mut result.warnings);
|
record_minimum_warnings(window, rect, common, &mut result.warnings);
|
||||||
apply_maximums(window, &mut rect, common, &mut result.warnings);
|
apply_maximums(window, &mut rect, common, &mut result.warnings);
|
||||||
if header > 0.0 {
|
if column.windows.len() > 1 {
|
||||||
result.tab_bars.push(crate::TabBar {
|
result.tab_bars.push(crate::TabBar {
|
||||||
group: crate::TabGroup {
|
group: crate::TabGroup {
|
||||||
windows: column.windows.clone(),
|
windows: column.windows.clone(),
|
||||||
active: window,
|
active: window,
|
||||||
},
|
},
|
||||||
rect: Rect::new(rect.x, rect.y - header, rect.width, header),
|
rect,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
result.geometry.insert(window, rect);
|
result.geometry.insert(window, rect);
|
||||||
@@ -2798,3 +2793,38 @@ mod tabbed_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod overlay_tab_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_indicator_never_reserves_layout_space() {
|
||||||
|
let mut layout = ScrollingLayout::default();
|
||||||
|
let a = WindowId(1);
|
||||||
|
let b = WindowId(2);
|
||||||
|
layout.insert(a, None).unwrap();
|
||||||
|
layout.toggle_column_tabbed(a).unwrap();
|
||||||
|
let bounds = Rect::new(0., 0., 800., 600.);
|
||||||
|
let gaps = GapConfig {
|
||||||
|
inner: 12.,
|
||||||
|
outer: 8.,
|
||||||
|
smart: false,
|
||||||
|
};
|
||||||
|
let before = layout
|
||||||
|
.resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(a))
|
||||||
|
.unwrap();
|
||||||
|
layout.insert(b, Some(a)).unwrap();
|
||||||
|
layout.move_into_column(b, a).unwrap();
|
||||||
|
let after = layout
|
||||||
|
.resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(b))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(before.geometry[&a], after.geometry[&b]);
|
||||||
|
assert_eq!(after.tab_bars[0].rect, after.geometry[&b]);
|
||||||
|
layout.focus_and_reveal(a).unwrap();
|
||||||
|
let switched = layout
|
||||||
|
.resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(a))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(switched.geometry[&a], after.geometry[&b]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
-6
@@ -559,12 +559,50 @@ column's width and scroll position. Vertical resizing is inactive in tabbed mode
|
|||||||
`join-tab` explicitly makes the destination column tabbed, including when joining
|
`join-tab` explicitly makes the destination column tabbed, including when joining
|
||||||
two rows that already share a column.
|
two rows that already share a column.
|
||||||
|
|
||||||
The tab bar shows window titles above the active window and reserves space
|
Tree groups show a compact title bar above the active window. Scrolling columns
|
||||||
inside its allocation. Click a title to switch. New windows open beside the
|
use a segmented rail along the left edge. Click a segment to select its tab, or
|
||||||
group. Closing, floating, or moving a tab to another workspace removes it from
|
hover over the rail to open a title list. The list stays open while the pointer
|
||||||
the group; a single remaining window loses its tab bar. Maximized and fullscreen
|
is over the rail or list, and scrolls if it is taller than the screen. Keyboard
|
||||||
windows hide the bar. Fullscreen remains per-window: restore before grouping or
|
tab switching briefly shows `n/N · title` near the top of the window.
|
||||||
switching tabs. Switching layout modes keeps every window but dissolves groups.
|
|
||||||
|
The scrolling indicator and its title list are overlays: neither reserves space
|
||||||
|
nor changes the window size. With at least 8px of gap to the left, the rail sits
|
||||||
|
in that gap; otherwise it sits just inside the window's left edge. Its invisible
|
||||||
|
click area is wider than the rail. On very short windows, the segments compress
|
||||||
|
to stay within the visible window height.
|
||||||
|
|
||||||
|
New windows open beside the group. Closing, floating, or moving a tab to another
|
||||||
|
workspace removes it from the group. Single-window groups, maximized windows and
|
||||||
|
fullscreen windows hide the tab controls. Fullscreen remains per-window: restore
|
||||||
|
before grouping or switching tabs. Switching layout modes keeps every window but
|
||||||
|
dissolves groups.
|
||||||
|
|
||||||
|
Configure the scrolling indicator under `scrolling.tab-indicator`:
|
||||||
|
|
||||||
|
```kdl
|
||||||
|
scrolling {
|
||||||
|
tab-indicator {
|
||||||
|
width 4
|
||||||
|
segment-height 18
|
||||||
|
segment-gap 4
|
||||||
|
hit-width 12
|
||||||
|
gap-threshold 8
|
||||||
|
list-width 220
|
||||||
|
row-height 28
|
||||||
|
padding 4
|
||||||
|
font-size 12
|
||||||
|
flash-duration-ms 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sizes are logical pixels. `flash-duration-ms 0` disables keyboard feedback.
|
||||||
|
Optional `active-color`, `inactive-color`, `background-color`, and `text-color`
|
||||||
|
accept the same color syntax as theme colors. When omitted, they follow the
|
||||||
|
current theme's accent, border, base surface, and primary text respectively.
|
||||||
|
The rail has fully rounded ends; the title list follows the shell corner radius.
|
||||||
|
Both use flat fills, without blur or shadows. Changes apply on configuration
|
||||||
|
reload.
|
||||||
|
|
||||||
To make future scrolling columns tabbed by default:
|
To make future scrolling columns tabbed by default:
|
||||||
|
|
||||||
|
|||||||
@@ -178,3 +178,7 @@ binding "Super+Q" disabled=#true
|
|||||||
|
|
||||||
See [Commands and bindings](configuration.md#commands-and-bindings) for action
|
See [Commands and bindings](configuration.md#commands-and-bindings) for action
|
||||||
names, arguments, and physical-key matching.
|
names, arguments, and physical-key matching.
|
||||||
|
|
||||||
|
In scrolling columns, hover over the left-edge tab indicator for window titles.
|
||||||
|
Click a segment or title to switch tabs. Keyboard tab switching briefly shows the
|
||||||
|
selected tab number and title without opening the list.
|
||||||
@@ -37,7 +37,8 @@ class TabGroupsTest(unittest.TestCase):
|
|||||||
runtime.mkdir(mode=0o700)
|
runtime.mkdir(mode=0o700)
|
||||||
config = root / "config/ferese"
|
config = root / "config/ferese"
|
||||||
config.mkdir(parents=True)
|
config.mkdir(parents=True)
|
||||||
(config / "config.kdl").write_text(f'layout {{ mode "{mode}"; }}\nanimations {{ enabled #false; }}\n')
|
(config / "config.kdl").write_text(f'layout {{ mode "{mode}"; }}\nanimations {{ enabled #false; }}\n'
|
||||||
|
+ 'scrolling { tab-indicator { active-color "#ff0000"; inactive-color "#00ff00"; }; }\n')
|
||||||
host = Path(os.environ["WAYLAND_DISPLAY"])
|
host = Path(os.environ["WAYLAND_DISPLAY"])
|
||||||
if not host.is_absolute():
|
if not host.is_absolute():
|
||||||
host = Path(os.environ["XDG_RUNTIME_DIR"]) / host
|
host = Path(os.environ["XDG_RUNTIME_DIR"]) / host
|
||||||
@@ -117,6 +118,20 @@ class TabGroupsTest(unittest.TestCase):
|
|||||||
captured = subprocess.check_output([str(ctl), "screenshot"],
|
captured = subprocess.check_output([str(ctl), "screenshot"],
|
||||||
env=ipc_environment(env), stderr=log, timeout=10)
|
env=ipc_environment(env), stderr=log, timeout=10)
|
||||||
image = Image.open(io.BytesIO(captured)).convert("RGB")
|
image = Image.open(io.BytesIO(captured)).convert("RGB")
|
||||||
|
if mode == "scrolling":
|
||||||
|
# The overlay rail sits inside the left edge at the default 4px gap.
|
||||||
|
for index, window_id in enumerate(ids):
|
||||||
|
x = round((active["x"] + 2) * scale)
|
||||||
|
y = round((active["y"] + active["height"] / 2 + (index - 1) * 22) * scale)
|
||||||
|
r, g, b = image.getpixel((x, y))
|
||||||
|
if window_id == active["id"]:
|
||||||
|
if r < 180 or g > 60 or b > 60:
|
||||||
|
return False
|
||||||
|
elif g < 180 or r > 60 or b > 60:
|
||||||
|
return False
|
||||||
|
self.assertEqual(active["y"], current_neighbor["y"])
|
||||||
|
self.assertEqual(active["height"], current_neighbor["height"])
|
||||||
|
return True
|
||||||
for index in range(3):
|
for index in range(3):
|
||||||
left = active["x"] + active["width"] * index / 3 + 4
|
left = active["x"] + active["width"] * index / 3 + 4
|
||||||
right = active["x"] + active["width"] * (index + 1) / 3 - 4
|
right = active["x"] + active["width"] * (index + 1) / 3 - 4
|
||||||
|
|||||||
Reference in new issue
Block a user