Implement tab indicators

This commit is contained in:
Ivan Molodetskikh
2025-02-02 08:41:42 +03:00
parent 1515410012
commit a451f75917
9 changed files with 417 additions and 14 deletions
+93
View File
@@ -445,6 +445,8 @@ pub struct Layout {
#[knuffel(child, default)]
pub shadow: Shadow,
#[knuffel(child, default)]
pub tab_indicator: TabIndicator,
#[knuffel(child, default)]
pub insert_hint: InsertHint,
#[knuffel(child, unwrap(children), default)]
pub preset_column_widths: Vec<PresetSize>,
@@ -472,6 +474,7 @@ impl Default for Layout {
focus_ring: Default::default(),
border: Default::default(),
shadow: Default::default(),
tab_indicator: Default::default(),
insert_hint: Default::default(),
preset_column_widths: Default::default(),
default_column_width: Default::default(),
@@ -676,6 +679,49 @@ pub struct ShadowOffset {
pub y: FloatOrInt<-65535, 65535>,
}
#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)]
pub struct TabIndicator {
#[knuffel(child)]
pub off: bool,
#[knuffel(child, unwrap(argument), default = Self::default().gap)]
pub gap: FloatOrInt<-65535, 65535>,
#[knuffel(child, unwrap(argument), default = Self::default().width)]
pub width: FloatOrInt<0, 65535>,
#[knuffel(child, default = Self::default().length)]
pub length: TabIndicatorLength,
#[knuffel(child)]
pub active_color: Option<Color>,
#[knuffel(child)]
pub inactive_color: Option<Color>,
#[knuffel(child)]
pub active_gradient: Option<Gradient>,
#[knuffel(child)]
pub inactive_gradient: Option<Gradient>,
}
impl Default for TabIndicator {
fn default() -> Self {
Self {
off: false,
gap: FloatOrInt(5.),
width: FloatOrInt(4.),
length: TabIndicatorLength {
total_proportion: Some(0.5),
},
active_color: None,
inactive_color: None,
active_gradient: None,
inactive_gradient: None,
}
}
}
#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)]
pub struct TabIndicatorLength {
#[knuffel(property)]
pub total_proportion: Option<f64>,
}
#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)]
pub struct InsertHint {
#[knuffel(child)]
@@ -1101,6 +1147,8 @@ pub struct WindowRule {
pub border: BorderRule,
#[knuffel(child, default)]
pub shadow: ShadowRule,
#[knuffel(child, default)]
pub tab_indicator: TabIndicatorRule,
#[knuffel(child, unwrap(argument))]
pub draw_border_with_background: Option<bool>,
#[knuffel(child, unwrap(argument))]
@@ -1202,6 +1250,18 @@ pub struct ShadowRule {
pub inactive_color: Option<Color>,
}
#[derive(knuffel::Decode, Debug, Default, Clone, Copy, PartialEq)]
pub struct TabIndicatorRule {
#[knuffel(child)]
pub active_color: Option<Color>,
#[knuffel(child)]
pub inactive_color: Option<Color>,
#[knuffel(child)]
pub active_gradient: Option<Gradient>,
#[knuffel(child)]
pub inactive_gradient: Option<Gradient>,
}
#[derive(knuffel::Decode, Debug, Clone, Copy, PartialEq)]
pub struct FloatingPosition {
#[knuffel(property)]
@@ -2039,6 +2099,23 @@ impl ShadowRule {
}
}
impl TabIndicatorRule {
pub fn merge_with(&mut self, other: &Self) {
if let Some(x) = other.active_color {
self.active_color = Some(x);
}
if let Some(x) = other.inactive_color {
self.inactive_color = Some(x);
}
if let Some(x) = other.active_gradient {
self.active_gradient = Some(x);
}
if let Some(x) = other.inactive_gradient {
self.inactive_gradient = Some(x);
}
}
}
impl CornerRadius {
pub fn fit_to(self, width: f32, height: f32) -> Self {
// Like in CSS: https://drafts.csswg.org/css-backgrounds/#corner-overlap
@@ -3473,6 +3550,10 @@ mod tests {
offset x=10 y=-20
}
tab-indicator {
width 10
}
preset-column-widths {
proportion 0.25
proportion 0.5
@@ -3571,6 +3652,10 @@ mod tests {
on
width 8.5
}
tab-indicator {
active-color "#f00"
}
}
layer-rule {
@@ -3729,6 +3814,10 @@ mod tests {
},
..Default::default()
},
tab_indicator: TabIndicator {
width: FloatOrInt(10.),
..Default::default()
},
insert_hint: InsertHint {
off: false,
color: Color::from_rgba8_unpremul(255, 200, 127, 255),
@@ -3875,6 +3964,10 @@ mod tests {
width: Some(FloatOrInt(8.5)),
..Default::default()
},
tab_indicator: TabIndicatorRule {
active_color: Some(Color::from_rgba8_unpremul(255, 0, 0, 255)),
..Default::default()
},
..Default::default()
}],
layer_rules: vec![
+4
View File
@@ -262,4 +262,8 @@ impl FocusRing {
pub fn is_off(&self) -> bool {
self.config.off
}
pub fn config(&self) -> &niri_config::FocusRing {
&self.config
}
}
+4
View File
@@ -80,6 +80,7 @@ pub mod monitor;
pub mod opening_window;
pub mod scrolling;
pub mod shadow;
pub mod tab_indicator;
pub mod tile;
pub mod workspace;
@@ -312,6 +313,7 @@ pub struct Options {
pub focus_ring: niri_config::FocusRing,
pub border: niri_config::Border,
pub shadow: niri_config::Shadow,
pub tab_indicator: niri_config::TabIndicator,
pub insert_hint: niri_config::InsertHint,
pub center_focused_column: CenterFocusedColumn,
pub always_center_single_column: bool,
@@ -337,6 +339,7 @@ impl Default for Options {
focus_ring: Default::default(),
border: Default::default(),
shadow: Default::default(),
tab_indicator: Default::default(),
insert_hint: Default::default(),
center_focused_column: Default::default(),
always_center_single_column: false,
@@ -550,6 +553,7 @@ impl Options {
focus_ring: layout.focus_ring,
border: layout.border,
shadow: layout.shadow,
tab_indicator: layout.tab_indicator,
insert_hint: layout.insert_hint,
center_focused_column: layout.center_focused_column,
always_center_single_column: layout.always_center_single_column,
+67 -10
View File
@@ -11,6 +11,7 @@ use smithay::utils::{Logical, Point, Rectangle, Scale, Serial, Size};
use super::closing_window::{ClosingWindow, ClosingWindowRenderElement};
use super::insert_hint_element::{InsertHintElement, InsertHintRenderElement};
use super::tab_indicator::{TabIndicator, TabIndicatorRenderElement, TabInfo};
use super::tile::{Tile, TileRenderElement, TileRenderSnapshot};
use super::workspace::{InteractiveResize, ResolvedSize};
use super::{ConfigureIntent, InteractiveResizeData, LayoutElement, Options, RemovedTile};
@@ -94,6 +95,7 @@ niri_render_elements! {
ScrollingSpaceRenderElement<R> => {
Tile = TileRenderElement<R>,
ClosingWindow = ClosingWindowRenderElement,
TabIndicator = TabIndicatorRenderElement,
InsertHint = InsertHintRenderElement,
}
}
@@ -174,6 +176,9 @@ pub struct Column<W: LayoutElement> {
/// How this column displays and arranges windows.
display_mode: ColumnDisplay,
/// Tab indicator for the tabbed display mode.
tab_indicator: TabIndicator,
/// Animation of the render offset during window swapping.
move_animation: Option<Animation>,
@@ -2527,19 +2532,44 @@ impl<W: LayoutElement> ScrollingSpace<W> {
}
let mut first = true;
for (tile, tile_pos, visible) in self.tiles_with_render_positions() {
// For the active tile (which comes first), draw the focus ring.
let focus_ring = focus_ring && first;
first = false;
if !visible {
continue;
// This matches self.tiles_in_render_order().
let view_off = Point::from((-self.view_pos(), 0.));
for (col, col_x) in self.columns_in_render_order() {
let col_off = Point::from((col_x, 0.));
let col_render_off = col.render_offset();
// Draw the tab indicator on top.
{
// This is the "static tile position" so to say: it excludes the tile offset (used
// for e.g. centering smaller tiles in always-center) and the tile render offset
// (used for tile-specific animations).
let pos = view_off + col_off + col_render_off + col.tiles_origin();
let pos = pos.to_physical_precise_round(scale).to_logical(scale);
rv.extend(col.tab_indicator.render(renderer, pos).map(Into::into));
}
rv.extend(
tile.render(renderer, tile_pos, scale, focus_ring, target)
.map(Into::into),
);
for (tile, tile_off, visible) in col.tiles_in_render_order() {
let tile_pos =
view_off + col_off + col_render_off + tile_off + tile.render_offset();
// Round to physical pixels.
let tile_pos = tile_pos.to_physical_precise_round(scale).to_logical(scale);
// And now the drawing logic.
// For the active tile (which comes first), draw the focus ring.
let focus_ring = focus_ring && first;
first = false;
if !visible {
continue;
}
rv.extend(
tile.render(renderer, tile_pos, scale, focus_ring, target)
.map(Into::into),
);
}
}
rv
@@ -3268,6 +3298,7 @@ impl<W: LayoutElement> Column<W> {
is_full_width,
is_fullscreen: false,
display_mode,
tab_indicator: TabIndicator::new(options.tab_indicator),
move_animation: None,
view_size,
working_area,
@@ -3326,6 +3357,7 @@ impl<W: LayoutElement> Column<W> {
data.update(tile);
}
self.tab_indicator.update_config(options.tab_indicator);
self.view_size = view_size;
self.working_area = working_area;
self.scale = scale;
@@ -3361,6 +3393,31 @@ impl<W: LayoutElement> Column<W> {
tile_view_rect.loc -= tile_off + tile.render_offset();
tile.update_render_elements(is_active, tile_view_rect);
}
let (tile, tile_off) = self.tiles().nth(self.active_tile_idx).unwrap();
let mut tile_view_rect = view_rect;
tile_view_rect.loc -= tile_off + tile.render_offset();
let config = self.tab_indicator.config();
let tabs = self.tiles.iter().enumerate().map(|(tile_idx, tile)| {
let is_active = tile_idx == active_idx;
TabInfo::from_tile(tile, is_active, &config)
});
// Hide the tab indicator in fullscreen. If you have it configured to overlap the window,
// you don't want that to happen in fullscreen. Also, laying things out correctly when the
// tab indicator is within the column and the column goes fullscreen, would require too
// many changes to the code for too little benefit (it's mostly invisible anyway).
let enabled = self.display_mode == ColumnDisplay::Tabbed && !self.is_fullscreen;
self.tab_indicator.update_render_elements(
enabled,
tile.animated_tile_size(),
tile_view_rect,
tabs,
is_active,
self.scale,
);
}
pub fn render_offset(&self) -> Point<f64, Logical> {
+201
View File
@@ -0,0 +1,201 @@
use std::iter::zip;
use niri_config::{CornerRadius, Gradient, GradientRelativeTo};
use smithay::utils::{Logical, Point, Rectangle, Size};
use super::tile::Tile;
use super::LayoutElement;
use crate::niri_render_elements;
use crate::render_helpers::border::BorderRenderElement;
use crate::render_helpers::renderer::NiriRenderer;
use crate::utils::{floor_logical_in_physical_max1, round_logical_in_physical};
#[derive(Debug)]
pub struct TabIndicator {
shader_locs: Vec<Point<f64, Logical>>,
shaders: Vec<BorderRenderElement>,
config: niri_config::TabIndicator,
}
#[derive(Debug)]
pub struct TabInfo {
pub gradient: Gradient,
}
niri_render_elements! {
TabIndicatorRenderElement => {
Gradient = BorderRenderElement,
}
}
impl TabIndicator {
pub fn new(config: niri_config::TabIndicator) -> Self {
Self {
shader_locs: Vec::new(),
shaders: Vec::new(),
config,
}
}
pub fn update_config(&mut self, config: niri_config::TabIndicator) {
self.config = config;
}
pub fn update_shaders(&mut self) {
for elem in &mut self.shaders {
elem.damage_all();
}
}
pub fn update_render_elements(
&mut self,
enabled: bool,
tile_size: Size<f64, Logical>,
tile_view_rect: Rectangle<f64, Logical>,
tabs: impl Iterator<Item = TabInfo> + Clone,
// TODO: do we indicate inactive-but-selected somehow?
_is_active: bool,
scale: f64,
) {
if !enabled || self.config.off {
self.shader_locs.clear();
self.shaders.clear();
return;
}
// Tab indicators are rendered relative to the tile geometry.
let tile_geo = Rectangle::new(Point::from((0., 0.)), tile_size);
let round = |logical: f64| round_logical_in_physical(scale, logical);
let width = round(self.config.width.0);
let gap = round(self.config.gap.0);
let total_prop = self.config.length.total_proportion.unwrap_or(0.5);
let min_length = round(tile_size.h * total_prop.clamp(0., 2.));
let count = tabs.clone().count();
self.shaders.resize_with(count, Default::default);
self.shader_locs.resize_with(count, Default::default);
let pixel = 1. / scale;
let shortest_length = count as f64 * pixel;
let length = f64::max(min_length, shortest_length);
let px_per_tab = length / count as f64;
let px_per_tab = floor_logical_in_physical_max1(scale, px_per_tab);
let floored_length = count as f64 * px_per_tab;
let mut ones_left = ((length - floored_length) / pixel).max(0.).round() as usize;
let mut shader_loc = Point::from((-gap - width, round((tile_size.h - length) / 2.)));
for ((shader, loc), tab) in zip(&mut self.shaders, &mut self.shader_locs).zip(tabs) {
*loc = shader_loc;
let mut px_per_tab = px_per_tab;
if ones_left > 0 {
ones_left -= 1;
px_per_tab += pixel;
}
shader_loc.y += px_per_tab;
let shader_size = Size::from((width, px_per_tab));
let mut gradient_area = match tab.gradient.relative_to {
GradientRelativeTo::Window => tile_geo,
GradientRelativeTo::WorkspaceView => tile_view_rect,
};
gradient_area.loc -= *loc;
shader.update(
shader_size,
gradient_area,
tab.gradient.in_,
tab.gradient.from,
tab.gradient.to,
((tab.gradient.angle as f32) - 90.).to_radians(),
Rectangle::from_size(shader_size),
0.,
CornerRadius::default(),
scale as f32,
1.,
);
}
}
pub fn render(
&self,
renderer: &mut impl NiriRenderer,
tile_pos: Point<f64, Logical>,
) -> impl Iterator<Item = TabIndicatorRenderElement> + '_ {
let has_border_shader = BorderRenderElement::has_shader(renderer);
if !has_border_shader {
return None.into_iter().flatten();
}
let rv = zip(&self.shaders, &self.shader_locs)
.map(move |(shader, loc)| shader.clone().with_location(tile_pos + *loc))
.map(TabIndicatorRenderElement::from);
Some(rv).into_iter().flatten()
}
pub fn config(&self) -> niri_config::TabIndicator {
self.config
}
}
impl TabInfo {
pub fn from_tile<W: LayoutElement>(
tile: &Tile<W>,
is_active: bool,
config: &niri_config::TabIndicator,
) -> Self {
let rules = tile.window().rules();
let rule = rules.tab_indicator;
let gradient_from_rule = || {
let (color, gradient) = if is_active {
(rule.active_color, rule.active_gradient)
} else {
(rule.inactive_color, rule.inactive_gradient)
};
let color = color.map(Gradient::from);
gradient.or(color)
};
let gradient_from_config = || {
let (color, gradient) = if is_active {
(config.active_color, config.active_gradient)
} else {
(config.inactive_color, config.inactive_gradient)
};
let color = color.map(Gradient::from);
gradient.or(color)
};
let gradient_from_border = || {
// Come up with tab indicator gradient matching the focus ring or the border, whichever
// one is enabled.
let focus_ring_config = tile.focus_ring().config();
let border_config = tile.border().config();
let config = if focus_ring_config.off {
border_config
} else {
focus_ring_config
};
let (color, gradient) = if is_active {
(config.active_color, config.active_gradient)
} else {
(config.inactive_color, config.inactive_gradient)
};
gradient.unwrap_or_else(|| Gradient::from(color))
};
let gradient = gradient_from_rule()
.or_else(gradient_from_config)
.unwrap_or_else(gradient_from_border);
TabInfo { gradient }
}
}
+20 -1
View File
@@ -1,6 +1,6 @@
use std::cell::Cell;
use niri_config::{FloatOrInt, OutputName, WorkspaceName, WorkspaceReference};
use niri_config::{FloatOrInt, OutputName, TabIndicatorLength, WorkspaceName, WorkspaceReference};
use proptest::prelude::*;
use proptest_derive::Arbitrary;
use smithay::output::{Mode, PhysicalProperties, Subpixel};
@@ -3239,6 +3239,23 @@ prop_compose! {
}
}
prop_compose! {
fn arbitrary_tab_indicator()(
off in any::<bool>(),
width in arbitrary_spacing(),
gap in arbitrary_spacing_neg(),
length in (0f64..2f64),
) -> niri_config::TabIndicator {
niri_config::TabIndicator {
off,
width: FloatOrInt(width),
gap: FloatOrInt(gap),
length: TabIndicatorLength { total_proportion: Some(length) },
..Default::default()
}
}
}
prop_compose! {
fn arbitrary_options()(
gaps in arbitrary_spacing(),
@@ -3246,6 +3263,7 @@ prop_compose! {
focus_ring in arbitrary_focus_ring(),
border in arbitrary_border(),
shadow in arbitrary_shadow(),
tab_indicator in arbitrary_tab_indicator(),
center_focused_column in arbitrary_center_focused_column(),
always_center_single_column in any::<bool>(),
empty_workspace_above_first in any::<bool>(),
@@ -3259,6 +3277,7 @@ prop_compose! {
focus_ring,
border,
shadow,
tab_indicator,
..Default::default()
}
}
+10 -2
View File
@@ -563,7 +563,7 @@ impl<W: LayoutElement> Tile<W> {
size
}
fn animated_window_size(&self) -> Size<f64, Logical> {
pub fn animated_window_size(&self) -> Size<f64, Logical> {
let mut size = self.window_size();
if let Some(resize) = &self.resize_animation {
@@ -580,7 +580,7 @@ impl<W: LayoutElement> Tile<W> {
size
}
fn animated_tile_size(&self) -> Size<f64, Logical> {
pub fn animated_tile_size(&self) -> Size<f64, Logical> {
let mut size = self.animated_window_size();
if self.is_fullscreen {
@@ -1031,6 +1031,14 @@ impl<W: LayoutElement> Tile<W> {
self.unmap_snapshot.take()
}
pub fn border(&self) -> &FocusRing {
&self.border
}
pub fn focus_ring(&self) -> &FocusRing {
&self.focus_ring
}
pub fn options(&self) -> &Rc<Options> {
&self.options
}
+8
View File
@@ -127,6 +127,14 @@ pub fn round_logical_in_physical_max1(scale: f64, logical: f64) -> f64 {
(logical * scale).max(1.).round() / scale
}
pub fn floor_logical_in_physical_max1(scale: f64, logical: f64) -> f64 {
if logical == 0. {
return 0.;
}
(logical * scale).max(1.).floor() / scale
}
pub fn output_size(output: &Output) -> Size<f64, Logical> {
let output_scale = output.current_scale().fractional_scale();
let output_transform = output.current_transform();
+10 -1
View File
@@ -2,7 +2,7 @@ use std::cmp::{max, min};
use niri_config::{
BlockOutFrom, BorderRule, CornerRadius, FloatingPosition, Match, PresetSize, ShadowRule,
WindowRule,
TabIndicatorRule, WindowRule,
};
use niri_ipc::ColumnDisplay;
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
@@ -83,6 +83,8 @@ pub struct ResolvedWindowRules {
pub border: BorderRule,
/// Shadow overrides.
pub shadow: ShadowRule,
/// Tab indicator overrides.
pub tab_indicator: TabIndicatorRule,
/// Whether or not to draw the border with a solid background.
///
@@ -191,6 +193,12 @@ impl ResolvedWindowRules {
color: None,
inactive_color: None,
},
tab_indicator: TabIndicatorRule {
active_color: None,
inactive_color: None,
active_gradient: None,
inactive_gradient: None,
},
draw_border_with_background: None,
opacity: None,
geometry_corner_radius: None,
@@ -290,6 +298,7 @@ impl ResolvedWindowRules {
resolved.focus_ring.merge_with(&rule.focus_ring);
resolved.border.merge_with(&rule.border);
resolved.shadow.merge_with(&rule.shadow);
resolved.tab_indicator.merge_with(&rule.tab_indicator);
if let Some(x) = rule.draw_border_with_background {
resolved.draw_border_with_background = Some(x);