feat(layout): overlay scrolling tab indicators and hover titles

This commit is contained in:
Samuel Onoja committed 2026-10-12 23:52:25 +01:00
1 parent a3974c2dda
commit 042698ea61
21 files changed
+1223 -23

No files matched your search

+5
View File
@@ -4,6 +4,7 @@ mod input_motion;
mod layout;
mod loading;
mod outputs;
mod tab_indicator;
mod xwayland;
use std::collections::{HashMap, HashSet};
@@ -28,6 +29,7 @@ use outputs::*;
pub use outputs::{LidPolicy, OutputLayout, OutputModeRequest, OutputProfile, OutputSettings, OutputTransform};
use serde::Deserialize;
use smithay::input::keyboard::{Keycode, keysyms, xkb};
pub(crate) use tab_indicator::TabIndicatorSettings;
use crate::window_rules;
use crate::window_rules::{WindowRule, WindowRuleConfig};
@@ -167,6 +169,7 @@ impl Config {
focus_effect: self.focus_effect_settings()?,
default_column_width: self.default_column_width()?,
default_column_display: self.default_column_display(),
tab_indicator: self.tab_indicator_settings()?,
scrolling_focus_strategy: self.scrolling_focus_strategy(),
column_width_presets: self.width_presets()?,
animations_enabled: self.animations_enabled(),
@@ -230,6 +233,8 @@ enum LayoutModeValue {
#[derive(Debug, Default, Deserialize)]
struct ScrollingConfig {
#[serde(default)]
tab_indicator: tab_indicator::TabIndicatorConfig,
default_column_display: Option<ColumnDisplayValue>,
default_column_width: Option<ColumnWidthValue>,
focus_strategy: Option<FocusStrategyValue>,
+133
View File
@@ -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());
}
}
+8
View File
@@ -484,6 +484,7 @@ impl Ferese {
}
let position = self.constrain_pointer_position(&pointer, position);
self.update_tab_indicator_hover(position);
pointer.motion(
self,
self.surface_under(position),
@@ -528,6 +529,7 @@ impl Ferese {
let requested = self.clamp_pointer_position(position + delta);
let location = self.constrain_pointer_position(&pointer, requested);
self.update_tab_indicator_hover(location);
pointer.motion(
self,
self.surface_under(location),
@@ -712,6 +714,12 @@ impl Ferese {
return;
}
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(
pointer.current_location(),
if horizontal != 0.0 { horizontal } else { vertical },
+10
View File
@@ -576,6 +576,16 @@ impl Ferese {
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(
&mut self,
index: u32,
+5
View File
@@ -11,6 +11,7 @@ mod overview;
mod resources;
mod scene;
mod shader;
mod tab_indicator;
mod tabs;
use std::collections::HashMap;
use std::sync::Arc;
@@ -332,6 +333,10 @@ pub(crate) enum OverviewChromePart {
Caption,
Tab,
TabActive,
TabRail,
TabList,
TabRow,
TabFlash,
}
#[derive(Debug)]
+7 -3
View File
@@ -122,9 +122,13 @@ pub(super) fn overview_strip_elements(
&& let Some(cache) = state.render.outputs.get_mut(&output_id)
{
cache.chrome.retain(|(id, part), _| match part {
OverviewChromePart::Caption | OverviewChromePart::Tab | OverviewChromePart::TabActive => {
state.windows.window(ferese_layout::WindowId(*id)).is_some()
}
OverviewChromePart::Caption
| 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::Strip => true,
});
+10 -1
View File
@@ -71,7 +71,16 @@ impl RenderResources {
self.windows.remove(&id);
for output in self.outputs.values_mut() {
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
)
});
}
}
+247
View File
@@ -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
}
+14
View File
@@ -24,6 +24,20 @@ pub(super) fn tab_elements(
let Some(sample) = frame.windows.get(&active) else {
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 {
return Vec::new();
}
+8
View File
@@ -7,6 +7,7 @@ mod layout;
mod navigation;
mod outputs;
mod reconciliation;
pub(crate) mod tab_indicator;
mod tabs;
#[cfg(test)]
mod window_lifecycle_tests;
@@ -456,6 +457,8 @@ pub struct Ferese {
workspace_slides: HashMap<OutputId, WorkspaceSlide>,
workspace_slide_offsets: HashMap<WorkspaceId, (f64, f64)>,
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_buttons: HashSet<u32>,
pub(crate) focus_history: ferese_core::FocusHistory,
@@ -576,6 +579,7 @@ pub struct RuntimeConfig {
pub focus_effect: crate::config::FocusEffectSettings,
pub default_column_width: ColumnWidth,
pub default_column_display: ferese_layout::ColumnDisplay,
pub(crate) tab_indicator: crate::config::TabIndicatorSettings,
pub scrolling_focus_strategy: ViewportFocusStrategy,
pub column_width_presets: Vec<ColumnWidth>,
pub animations_enabled: bool,
@@ -752,6 +756,8 @@ impl Ferese {
workspace_slides: HashMap::new(),
workspace_slide_offsets: HashMap::new(),
focused_window: None,
tab_indicator: tab_indicator::TabIndicatorState::default(),
tab_indicator_settings: config.tab_indicator,
tab_bars: HashMap::new(),
tab_buttons: HashSet::new(),
focus_history: Default::default(),
@@ -980,6 +986,8 @@ impl Ferese {
self.idle_inhibit = config.idle_inhibit;
self.theme_settings = config.theme_settings;
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_style = config.panel_style;
self.focus_effect = config.focus_effect;
+4 -1
View File
@@ -102,6 +102,7 @@ impl Ferese {
|| !self.dismissing_popups.is_empty()
|| self.render.snapshots().next().is_some()
|| self.overview.needs_tick()
|| self.tab_indicator.is_animating()
{
return true;
}
@@ -160,7 +161,9 @@ impl Ferese {
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();
for (output, slide) in &mut self.workspace_slides {
if slide.held_progress.is_some() {
+1
View File
@@ -640,6 +640,7 @@ impl Ferese {
self.rebuild_presentation_dependencies();
self.sync_window_stacking();
self.retarget_overview();
self.reconcile_tab_indicator();
if !finalize {
return;
}
+1
View File
@@ -197,6 +197,7 @@ impl Ferese {
}
self.relayout();
self.flash_tab_switch(current, next);
}
pub fn move_direction(&mut self, direction: Direction) {
+1
View File
@@ -196,6 +196,7 @@ impl Ferese {
.iter()
.any(|window| Some(window.output) == self.output_id(output))
|| self.overview.is_animating(self.spring_config)
|| self.tab_indicator_output_animating(output)
|| self
.dismissing_popups
.iter()
+558
View File
@@ -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());
}
}
+10 -2
View File
@@ -71,8 +71,10 @@ impl Ferese {
if self.workspaces.active().fullscreen.is_some() {
return;
}
if let Some(target) = self.workspaces.active().layout.adjacent_tab(window, previous) {
self.activate_managed_window(target);
if let Some(target) = self.workspaces.active().layout.adjacent_tab(window, previous)
&& self.activate_managed_window(target)
{
self.flash_tab_switch(window, target);
}
}
@@ -100,6 +102,12 @@ impl Ferese {
return None;
}
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.
if self.window_surface_under(position).is_some() {
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.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());
}
+39 -9
View File
@@ -834,16 +834,11 @@ impl ScrollingLayout {
let (column_x, column_width) = column_positions[column_index];
if column.display == ColumnDisplay::Tabbed {
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(
bounds.x + outer + column_x - self.viewport_x,
bounds.y + outer + header,
bounds.y + outer,
column_width,
viewport_height - header,
viewport_height,
);
// All tabs use one size, even when their client constraints differ.
// Keep the column allocation stable and honor compatible maximums.
@@ -864,13 +859,13 @@ impl ScrollingLayout {
let common = normalized_constraints(common);
record_minimum_warnings(window, 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 {
group: crate::TabGroup {
windows: column.windows.clone(),
active: window,
},
rect: Rect::new(rect.x, rect.y - header, rect.width, header),
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
View File
@@ -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
two rows that already share a column.
The tab bar shows window titles above the active window and reserves space
inside its allocation. Click a title to switch. New windows open beside the
group. Closing, floating, or moving a tab to another workspace removes it from
the group; a single remaining window loses its tab bar. Maximized and fullscreen
windows hide the bar. Fullscreen remains per-window: restore before grouping or
switching tabs. Switching layout modes keeps every window but dissolves groups.
Tree groups show a compact title bar above the active window. Scrolling columns
use a segmented rail along the left edge. Click a segment to select its tab, or
hover over the rail to open a title list. The list stays open while the pointer
is over the rail or list, and scrolls if it is taller than the screen. Keyboard
tab switching briefly shows `n/N · title` near the top of the window.
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:
+4
View File
@@ -178,3 +178,7 @@ binding "Super+Q" disabled=#true
See [Commands and bindings](configuration.md#commands-and-bindings) for action
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.
+16 -1
View File
@@ -37,7 +37,8 @@ class TabGroupsTest(unittest.TestCase):
runtime.mkdir(mode=0o700)
config = root / "config/ferese"
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"])
if not host.is_absolute():
host = Path(os.environ["XDG_RUNTIME_DIR"]) / host
@@ -117,6 +118,20 @@ class TabGroupsTest(unittest.TestCase):
captured = subprocess.check_output([str(ctl), "screenshot"],
env=ipc_environment(env), stderr=log, timeout=10)
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):
left = active["x"] + active["width"] * index / 3 + 4
right = active["x"] + active["width"] * (index + 1) / 3 - 4