diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 493c09a9..edf294b1 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -2055,6 +2055,7 @@ impl ConfigClient { radius } + #[allow(dead_code)] pub fn seat_toggle_expand(&self, seat: Seat) { self.send(&ClientMessage::SeatToggleExpand { seat }); } @@ -2087,6 +2088,14 @@ impl ConfigClient { self.send(&ClientMessage::SetTabTitleAlign { align }); } + pub fn set_tab_from_top(&self, from_top: bool) { + self.send(&ClientMessage::SetTabFromTop { from_top }); + } + + pub fn set_tab_render_text(&self, render: bool) { + self.send(&ClientMessage::SetTabRenderText { render }); + } + pub fn seat_move_tab(&self, seat: Seat, right: bool) { self.send(&ClientMessage::SeatMoveTab { seat, right }); } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index be54e511..94d47b3e 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -911,6 +911,12 @@ pub enum ClientMessage<'a> { SetTabTitleAlign { align: u32, }, + SetTabFromTop { + from_top: bool, + }, + SetTabRenderText { + render: bool, + }, SeatMoveTab { seat: Seat, right: bool, diff --git a/jay-config/src/lib.rs b/jay-config/src/lib.rs index 220f0570..a4f1fe83 100644 --- a/jay-config/src/lib.rs +++ b/jay-config/src/lib.rs @@ -464,6 +464,21 @@ pub fn set_tab_title_align(align: &str) { get!().set_tab_title_align(val) } +/// Sets whether tab enter/exit animations slide from the top. +/// +/// When `true`, tabs slide in from above and out upwards. +/// When `false` (default), tabs slide in from below and out downwards. +pub fn set_tab_from_top(from_top: bool) { + get!().set_tab_from_top(from_top) +} + +/// Sets whether text is rendered on tab buttons. +/// +/// The default is `true`. +pub fn set_tab_render_text(render: bool) { + get!().set_tab_render_text(render) +} + /// Sets a callback to run when this config is unloaded. /// /// Only one callback can be set at a time. If another callback is already set, it will be diff --git a/jay-config/src/theme.rs b/jay-config/src/theme.rs index d1c9e9f5..5b02c94b 100644 --- a/jay-config/src/theme.rs +++ b/jay-config/src/theme.rs @@ -334,6 +334,30 @@ pub mod colors { /// /// Default: `#23092c`. const 23 => TAB_ATTENTION_BACKGROUND_COLOR, + /// The background color of a focused (keyboard-focused) tab. + /// + /// Default: `#3a5a70`. + const 24 => TAB_FOCUSED_BACKGROUND_COLOR, + /// The border color of a focused (keyboard-focused) tab. + /// + /// Default: `#285577`. + const 25 => TAB_FOCUSED_BORDER_COLOR, + /// The text color of a focused (keyboard-focused) tab. + /// + /// Default: `#dddddd`. + const 26 => TAB_FOCUSED_TEXT_COLOR, + /// The background color of an urgent tab. + /// + /// Default: `#23092c`. + const 27 => TAB_URGENT_BACKGROUND_COLOR, + /// The border color of an urgent tab. + /// + /// Default: `#441155`. + const 28 => TAB_URGENT_BORDER_COLOR, + /// The text color of an urgent tab. + /// + /// Default: `#ffffff`. + const 29 => TAB_URGENT_TEXT_COLOR, } /// Sets the color of GUI element. @@ -430,5 +454,9 @@ pub mod sized { /// /// Default: 4 const 12 => TAB_BAR_GAP, + /// The opacity of tabs in the tab bar (0-100). + /// + /// Default: 100 + const 13 => TAB_OPACITY, } } diff --git a/src/compositor.rs b/src/compositor.rs index c8fbb5e9..3ed593a6 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -399,10 +399,10 @@ fn start_compositor2( hyprland_global_shortcuts: Default::default(), layer_rules: Default::default(), blur_config: Default::default(), - blur_cache_epoch: Default::default(), animations_config: Default::default(), active_animations: Default::default(), close_snapshots: Default::default(), + tab_animation_containers: Default::default(), }); state.tracker.register(ClientId::from_raw(0)); create_dummy_output(&state); diff --git a/src/config/handler.rs b/src/config/handler.rs index 07734fe3..d7d9e0ea 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -2505,6 +2505,7 @@ impl ConfigProxyHandler { TAB_BAR_BORDER_WIDTH => ThemeSized::tab_bar_border_width, TAB_BAR_TEXT_PADDING => ThemeSized::tab_bar_text_padding, TAB_BAR_GAP => ThemeSized::tab_bar_gap, + TAB_OPACITY => ThemeSized::tab_opacity, _ => return Err(CphError::UnknownSized(sized.0)), }; Ok(sized) @@ -2584,10 +2585,16 @@ impl ConfigProxyHandler { HIGHLIGHT_COLOR => ThemeColor::highlight, TAB_ACTIVE_BACKGROUND_COLOR => ThemeColor::tab_active_background, TAB_ACTIVE_BORDER_COLOR => ThemeColor::tab_active_border, + TAB_FOCUSED_BACKGROUND_COLOR => ThemeColor::tab_focused_background, + TAB_FOCUSED_BORDER_COLOR => ThemeColor::tab_focused_border, TAB_INACTIVE_BACKGROUND_COLOR => ThemeColor::tab_inactive_background, TAB_INACTIVE_BORDER_COLOR => ThemeColor::tab_inactive_border, + TAB_URGENT_BACKGROUND_COLOR => ThemeColor::tab_urgent_background, + TAB_URGENT_BORDER_COLOR => ThemeColor::tab_urgent_border, TAB_ACTIVE_TEXT_COLOR => ThemeColor::tab_active_text, + TAB_FOCUSED_TEXT_COLOR => ThemeColor::tab_focused_text, TAB_INACTIVE_TEXT_COLOR => ThemeColor::tab_inactive_text, + TAB_URGENT_TEXT_COLOR => ThemeColor::tab_urgent_text, TAB_BAR_BACKGROUND_COLOR => ThemeColor::tab_bar_background, TAB_ATTENTION_BACKGROUND_COLOR => ThemeColor::tab_attention_background, _ => return Err(CphError::UnknownColor(colorable.0)), @@ -3525,6 +3532,12 @@ impl ConfigProxyHandler { }; self.state.theme.tab_title_align.set(val); } + ClientMessage::SetTabFromTop { from_top } => { + self.state.theme.tab_from_top.set(from_top); + } + ClientMessage::SetTabRenderText { render } => { + self.state.theme.tab_render_text.set(render); + } ClientMessage::SeatMoveTab { seat, right } => self .handle_seat_move_tab(seat, right) .wrn("seat_move_tab")?, diff --git a/src/cursor_user.rs b/src/cursor_user.rs index 6d911048..35c1078a 100644 --- a/src/cursor_user.rs +++ b/src/cursor_user.rs @@ -250,7 +250,8 @@ impl CursorUserGroup { } fn damage(&self, rect: Rect) { - self.state.damage2(true, self.hardware_cursor.get(), rect); + self.state + .damage2(true, self.hardware_cursor.get(), None, rect); } } diff --git a/src/gfx_api.rs b/src/gfx_api.rs index 61183b61..e9c3747b 100644 --- a/src/gfx_api.rs +++ b/src/gfx_api.rs @@ -116,6 +116,11 @@ pub struct BlurBackdrop { pub cache: Option>>>, pub cache_epoch: u64, pub cache_pixel_rect: [i32; 4], + /// Corner radius in physical pixels for rounded-rect clipping of the blur. + /// 0.0 = no rounding. + pub corner_radius: f32, + /// Rect size in physical pixels (width, height), used for SDF rounding. + pub pixel_size: [f32; 2], } impl std::fmt::Debug for BlurBackdrop { @@ -363,16 +368,6 @@ pub struct RoundedFillRect { pub z_order: u32, } -impl RoundedFillRect { - pub fn effective_color(&self) -> Color { - let mut color = self.color; - if let Some(alpha) = self.alpha { - color = color * alpha; - } - color - } -} - pub struct RoundedCopyTexture { pub tex: Rc, pub source: SampleRect, diff --git a/src/gfx_apis/gl.rs b/src/gfx_apis/gl.rs index 760936fa..9b0ecef6 100644 --- a/src/gfx_apis/gl.rs +++ b/src/gfx_apis/gl.rs @@ -660,8 +660,8 @@ fn render_blur_backdrop(fb: &Framebuffer, b: &BlurBackdrop) { .mask .as_ref() .and_then(|m| m.texture.as_gl().map(|t| (m, t))); - if let Some((mask, mask_tex_obj)) = mask_gl - && !mask_tex_obj.gl.external_only + if b.corner_radius > 0.0 + || matches!(mask_gl.as_ref(), Some((_, mask_tex_obj)) if !mask_tex_obj.gl.external_only) { // Masked composite: src = (blurred * weight, weight); blend = (ONE, ONE_MINUS_SRC_ALPHA). let prog = &ctx.blur_composite; @@ -670,14 +670,38 @@ fn render_blur_backdrop(fb: &Framebuffer, b: &BlurBackdrop) { (gles.glBindTexture)(GL_TEXTURE_2D, texs[0]); (gles.glUniform1i)(prog.tex, 0); (gles.glActiveTexture)(GL_TEXTURE1); - (gles.glBindTexture)(GL_TEXTURE_2D, mask_tex_obj.gl.tex); + let has_mask = if let Some((_, mask_tex_obj)) = mask_gl.as_ref() { + if !mask_tex_obj.gl.external_only { + (gles.glBindTexture)(GL_TEXTURE_2D, mask_tex_obj.gl.tex); + true + } else { + (gles.glBindTexture)(GL_TEXTURE_2D, texs[0]); + false + } + } else { + (gles.glBindTexture)(GL_TEXTURE_2D, texs[0]); + false + }; (gles.glTexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); (gles.glTexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); (gles.glTexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); (gles.glTexParameteri)(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); (gles.glUniform1i)(prog.mask_tex, 1); - (gles.glUniform1f)(prog.threshold, mask.threshold); - let mask_tc = mask.source.to_points(); + (gles.glUniform1f)( + prog.threshold, + mask_gl + .as_ref() + .map(|(mask, _)| mask.threshold) + .unwrap_or(1.0), + ); + (gles.glUniform1f)(prog.corner_radius, b.corner_radius); + (gles.glUniform2f)(prog.pixel_size, b.pixel_size[0], b.pixel_size[1]); + (gles.glUniform1f)(prog.mask_enabled, if has_mask { 1.0 } else { 0.0 }); + let mask_tc = mask_gl + .as_ref() + .filter(|(_, mask_tex_obj)| !mask_tex_obj.gl.external_only) + .map(|(mask, _)| mask.source.to_points()) + .unwrap_or(texcoord); (gles.glVertexAttribPointer)( prog.texcoord as _, 2, diff --git a/src/gfx_apis/gl/renderer/context.rs b/src/gfx_apis/gl/renderer/context.rs index d5508563..5f66bac5 100644 --- a/src/gfx_apis/gl/renderer/context.rs +++ b/src/gfx_apis/gl/renderer/context.rs @@ -83,6 +83,9 @@ pub(crate) struct BlurCompositeProg { pub(crate) tex: GLint, pub(crate) mask_tex: GLint, pub(crate) threshold: GLint, + pub(crate) corner_radius: GLint, + pub(crate) pixel_size: GLint, + pub(crate) mask_enabled: GLint, } pub(crate) struct RoundedFillProg { @@ -260,6 +263,9 @@ impl GlRenderContext { tex: prog.get_uniform_location(c"tex"), mask_tex: prog.get_uniform_location(c"mask_tex"), threshold: prog.get_uniform_location(c"threshold"), + corner_radius: prog.get_uniform_location(c"corner_radius"), + pixel_size: prog.get_uniform_location(c"pixel_size"), + mask_enabled: prog.get_uniform_location(c"mask_enabled"), prog, } }; diff --git a/src/gfx_apis/gl/shaders/blur_composite.frag.glsl b/src/gfx_apis/gl/shaders/blur_composite.frag.glsl index 5165ea1d..1cfcaf8d 100644 --- a/src/gfx_apis/gl/shaders/blur_composite.frag.glsl +++ b/src/gfx_apis/gl/shaders/blur_composite.frag.glsl @@ -5,6 +5,20 @@ varying vec2 v_mask_texcoord; uniform sampler2D tex; uniform sampler2D mask_tex; uniform float threshold; +uniform float corner_radius; +uniform vec2 pixel_size; +uniform float mask_enabled; + +float rounded_alpha(vec2 uv) { + if (corner_radius <= 0.0) + return 1.0; + + float radius = min(corner_radius, min(pixel_size.x, pixel_size.y) * 0.5); + vec2 coords = uv * pixel_size; + vec2 center = clamp(coords, vec2(radius), pixel_size - vec2(radius)); + float dist = distance(coords, center); + return 1.0 - smoothstep(radius - 0.5, radius + 0.5, dist); +} void main() { vec3 blurred = texture2D(tex, v_texcoord).rgb; @@ -12,6 +26,7 @@ void main() { float in_range = step(0.0, uv.x) * step(uv.x, 1.0) * step(0.0, uv.y) * step(uv.y, 1.0); float a = texture2D(mask_tex, clamp(uv, 0.0, 1.0)).a * in_range; - float weight = smoothstep(0.0, max(threshold, 0.001), a); + float mask_weight = mix(1.0, smoothstep(0.0, max(threshold, 0.001), a), mask_enabled); + float weight = mask_weight * rounded_alpha(v_texcoord); gl_FragColor = vec4(blurred * weight, weight); } diff --git a/src/gfx_apis/vulkan/blur.rs b/src/gfx_apis/vulkan/blur.rs index af781cea..f882fa3b 100644 --- a/src/gfx_apis/vulkan/blur.rs +++ b/src/gfx_apis/vulkan/blur.rs @@ -32,6 +32,8 @@ pub(super) struct BlurMaskRecord<'a> { pub(super) mask_source_points: [[f32; 2]; 4], pub(super) target_points: [[f32; 2]; 4], pub(super) threshold: f32, + pub(super) corner_radius: f32, + pub(super) pixel_size: [f32; 2], pub(super) _phantom: std::marker::PhantomData<&'a ()>, } @@ -145,6 +147,9 @@ impl VulkanRenderer { mask: Option<&BlurMaskRecord<'_>>, cached_blur: Option<&Rc>, out_blur_image: &mut Option>, + corner_radius: f32, + pixel_size: [f32; 2], + target_points: [[f32; 2]; 4], ) -> Result<(), VulkanError> { let [x1, y1, x2, y2] = rect; let x1 = x1.max(0).min(target.width as i32); @@ -414,7 +419,7 @@ impl VulkanRenderer { // After cascade: levels[0] in SHADER_READ_ONLY, target in TRANSFER_SRC. - if let Some(mask) = mask { + if mask.is_some() || corner_radius > 0.0 { // Masked composite path: restore target to COLOR_ATTACHMENT and // draw the composite shader sampling levels[0] + mask. do_barriers(&[barrier( @@ -464,18 +469,22 @@ impl VulkanRenderer { // Identity uv across blurred level 0 (full image is the blurred rect). let blurred_tc: [[f32; 2]; 4] = [[1.0, 0.0], [0.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; + let mask_points = mask.map(|m| m.mask_source_points).unwrap_or(blurred_tc); let push = BlurCompositePushConstants { - pos: mask.target_points, + pos: mask.map(|m| m.target_points).unwrap_or(target_points), blurred_tex_pos: blurred_tc, - mask_tex_pos: mask.mask_source_points, - threshold: mask.threshold, + mask_tex_pos: mask_points, + threshold: mask.map(|m| m.threshold).unwrap_or(1.0), + corner_radius, + pixel_size, + mask_enabled: if mask.is_some() { 1.0 } else { 0.0 }, }; let blurred_image_info = DescriptorImageInfo::default() .image_view(levels[0].texture_view) .image_layout(ImageLayout::SHADER_READ_ONLY_OPTIMAL); let mask_image_info = DescriptorImageInfo::default() - .image_view(mask.mask_view) + .image_view(mask.map(|m| m.mask_view).unwrap_or(levels[0].texture_view)) .image_layout(ImageLayout::SHADER_READ_ONLY_OPTIMAL); let writes = [ WriteDescriptorSet::default() @@ -671,6 +680,9 @@ impl VulkanRenderer { blurred_tex_pos: blurred_tc, mask_tex_pos: mask.mask_source_points, threshold: mask.threshold, + corner_radius: mask.corner_radius, + pixel_size: mask.pixel_size, + mask_enabled: 1.0, }; let blurred_image_info = DescriptorImageInfo::default() .image_view(cached.texture_view) diff --git a/src/gfx_apis/vulkan/renderer.rs b/src/gfx_apis/vulkan/renderer.rs index bf94d6f2..a1a68218 100644 --- a/src/gfx_apis/vulkan/renderer.rs +++ b/src/gfx_apis/vulkan/renderer.rs @@ -27,16 +27,15 @@ use { semaphore::VulkanSemaphore, shaders::{ BLUR_COMPOSITE_FRAG, BLUR_COMPOSITE_VERT, BLUR_DOWN_FRAG, BLUR_UP_FRAG, BLUR_VERT, - BlurCompositePushConstants, BlurPushConstants, - ColorManagementData, EotfArgs, FILL_FRAG, FILL_VERT, FillPushConstants, - InvEotfArgs, LEGACY_FILL_FRAG, LEGACY_FILL_VERT, LEGACY_ROUNDED_FILL_FRAG, - LEGACY_ROUNDED_FILL_VERT, LEGACY_ROUNDED_TEX_FRAG, LEGACY_ROUNDED_TEX_VERT, - LEGACY_TEX_FRAG, LEGACY_TEX_VERT, LegacyFillPushConstants, - LegacyRoundedFillPushConstants, LegacyRoundedTexPushConstants, - LegacyTexPushConstants, OUT_FRAG, OUT_VERT, OutPushConstants, ROUNDED_FILL_FRAG, - ROUNDED_FILL_VERT, ROUNDED_TEX_FRAG, ROUNDED_TEX_VERT, RoundedFillPushConstants, - RoundedTexPushConstants, TEX_FRAG, TEX_VERT, TexPushConstants, TexVertex, - VulkanShader, + BlurCompositePushConstants, BlurPushConstants, ColorManagementData, EotfArgs, + FILL_FRAG, FILL_VERT, FillPushConstants, InvEotfArgs, LEGACY_FILL_FRAG, + LEGACY_FILL_VERT, LEGACY_ROUNDED_FILL_FRAG, LEGACY_ROUNDED_FILL_VERT, + LEGACY_ROUNDED_TEX_FRAG, LEGACY_ROUNDED_TEX_VERT, LEGACY_TEX_FRAG, LEGACY_TEX_VERT, + LegacyFillPushConstants, LegacyRoundedFillPushConstants, + LegacyRoundedTexPushConstants, LegacyTexPushConstants, OUT_FRAG, OUT_VERT, + OutPushConstants, ROUNDED_FILL_FRAG, ROUNDED_FILL_VERT, ROUNDED_TEX_FRAG, + ROUNDED_TEX_VERT, RoundedFillPushConstants, RoundedTexPushConstants, TEX_FRAG, + TEX_VERT, TexPushConstants, TexVertex, VulkanShader, }, }, io_uring::IoUring, @@ -241,6 +240,8 @@ struct VulkanBlurOp { cache: Option>>>, cache_epoch: u64, cache_pixel_rect: [i32; 4], + corner_radius: f32, + pixel_size: [f32; 2], } struct VulkanBlurMask { @@ -1569,6 +1570,8 @@ impl VulkanRenderer { cache: b.cache.clone(), cache_epoch: b.cache_epoch, cache_pixel_rect: b.cache_pixel_rect, + corner_radius: b.corner_radius, + pixel_size: b.pixel_size, })); } } @@ -2305,6 +2308,8 @@ impl VulkanRenderer { mask_source_points: m.source.to_points(), target_points: blur.rect.to_points(), threshold: m.threshold, + corner_radius: blur.corner_radius, + pixel_size: blur.pixel_size, _phantom: std::marker::PhantomData, }); @@ -2341,6 +2346,9 @@ impl VulkanRenderer { mask_record.as_ref(), cached_blur.as_ref(), &mut produced_blur, + blur.corner_radius, + blur.pixel_size, + blur.rect.to_points(), )?; // On a masked cache miss, store the freshly-blurred image @@ -2922,7 +2930,23 @@ impl VulkanRenderer { // but they do paint pixels and need paint regions. (false, rf.rect) } - GfxApiOpt::RoundedCopyTexture(ct) => (false, ct.target), + GfxApiOpt::RoundedCopyTexture(c) => { + let opaque = 'opaque: { + if let Some(a) = c.alpha + && a < 1.0 + { + break 'opaque false; + } + if !c.opaque { + let tex = c.tex.as_vk(&self.device.device)?; + if tex.format.has_alpha { + break 'opaque false; + } + } + true + }; + (opaque, c.target) + } GfxApiOpt::BlurBackdrop(b) => { blur_rects.push(b.rect.to_rect(width, height)); continue; diff --git a/src/gfx_apis/vulkan/shaders.rs b/src/gfx_apis/vulkan/shaders.rs index a1894335..6ffef7cd 100644 --- a/src/gfx_apis/vulkan/shaders.rs +++ b/src/gfx_apis/vulkan/shaders.rs @@ -198,6 +198,9 @@ pub struct BlurCompositePushConstants { pub blurred_tex_pos: [[f32; 2]; 4], pub mask_tex_pos: [[f32; 2]; 4], pub threshold: f32, + pub corner_radius: f32, + pub pixel_size: [f32; 2], + pub mask_enabled: f32, } unsafe impl Packed for BlurCompositePushConstants {} diff --git a/src/gfx_apis/vulkan/shaders/blur_composite.frag b/src/gfx_apis/vulkan/shaders/blur_composite.frag index a6fdfc0d..0fdc775c 100644 --- a/src/gfx_apis/vulkan/shaders/blur_composite.frag +++ b/src/gfx_apis/vulkan/shaders/blur_composite.frag @@ -5,18 +5,33 @@ layout(set = 0, binding = 1) uniform sampler2D mask_tex; layout(push_constant, std430) uniform Data { layout(offset = 96) float threshold; + layout(offset = 100) float corner_radius; + layout(offset = 104) vec2 pixel_size; + layout(offset = 112) float mask_enabled; } data; layout(location = 0) in vec2 v_blurred_tex_pos; layout(location = 1) in vec2 v_mask_tex_pos; layout(location = 0) out vec4 out_color; +float rounded_alpha(vec2 uv) { + if (data.corner_radius <= 0.0) + return 1.0; + + float radius = min(data.corner_radius, min(data.pixel_size.x, data.pixel_size.y) * 0.5); + vec2 coords = uv * data.pixel_size; + vec2 center = clamp(coords, vec2(radius), data.pixel_size - vec2(radius)); + float dist = distance(coords, center); + return 1.0 - smoothstep(radius - 0.5, radius + 0.5, dist); +} + void main() { vec3 blurred = textureLod(blurred_tex, v_blurred_tex_pos, 0).rgb; vec2 uv = v_mask_tex_pos; float in_range = step(0.0, uv.x) * step(uv.x, 1.0) * step(0.0, uv.y) * step(uv.y, 1.0); float a = textureLod(mask_tex, clamp(uv, 0.0, 1.0), 0).a * in_range; - float weight = smoothstep(0.0, max(data.threshold, 0.001), a); + float mask_weight = mix(1.0, smoothstep(0.0, max(data.threshold, 0.001), a), data.mask_enabled); + float weight = mask_weight * rounded_alpha(v_blurred_tex_pos); out_color = vec4(blurred * weight, weight); } diff --git a/src/gfx_apis/vulkan/shaders_bin/blur_composite.frag.spv b/src/gfx_apis/vulkan/shaders_bin/blur_composite.frag.spv index 7f68ffb9..6d860bd4 100644 Binary files a/src/gfx_apis/vulkan/shaders_bin/blur_composite.frag.spv and b/src/gfx_apis/vulkan/shaders_bin/blur_composite.frag.spv differ diff --git a/src/gfx_apis/vulkan/shaders_hash.txt b/src/gfx_apis/vulkan/shaders_hash.txt index f8b7f0e8..36f4690e 100644 --- a/src/gfx_apis/vulkan/shaders_hash.txt +++ b/src/gfx_apis/vulkan/shaders_hash.txt @@ -1,6 +1,6 @@ 302a9f250bdc4f8e0e71a9f77c9a8a7aa55fd003bc91c2422a700c4abd83f54e src/gfx_apis/vulkan/shaders/alpha_modes.glsl 65acbe7a6496279fa22f520ad2036d3e14a7cb1707c6a509ce7858adc4a2dcba src/gfx_apis/vulkan/shaders/blur.vert -16ad6f1eb029ccce5e0204a7d79709b05a8a708133feaf8bb20a24371de25ed7 src/gfx_apis/vulkan/shaders/blur_composite.frag +e2ff39a764534debaa5857f7434fb552e9c10a83bab5d706c14240d4e704f7d2 src/gfx_apis/vulkan/shaders/blur_composite.frag 6399e23afa2e07c98b9fd1a4e853ea974a9958547ce65734846483bd7cbc8461 src/gfx_apis/vulkan/shaders/blur_composite.vert a04b2453c39efb018754fc25d45a369b5813359c55fad1c99020804cbb3a18e0 src/gfx_apis/vulkan/shaders/blur_down.frag f6d51f3b5410387d1474529c44e71bfdc31ceb80174ea6e3e4c2df30d03f11c3 src/gfx_apis/vulkan/shaders/blur_up.frag diff --git a/src/ifs/wl_surface.rs b/src/ifs/wl_surface.rs index e9ae9d7b..36e01fb0 100644 --- a/src/ifs/wl_surface.rs +++ b/src/ifs/wl_surface.rs @@ -1494,7 +1494,7 @@ impl WlSurface { if let Some(tl) = self.toplevel.get() { damage = damage.intersect(tl.node_absolute_position()); } - self.client.state.damage(damage); + self.client.state.damage_for_surface(self, damage); } } if self.visible.get() { @@ -1520,7 +1520,7 @@ impl WlSurface { // Frame requests must be dispatched at the highest possible frame rate. // Therefore we must trigger a vsync of the output as soon as possible. let rect = output.global.pos.get(); - self.client.state.damage(rect); + self.client.state.damage_for_surface(self, rect); } } else { if fifo_barrier_set { @@ -1555,7 +1555,7 @@ impl WlSurface { had_texture } - fn apply_damage(&self, pending: &PendingState) { + fn apply_damage(self: &Rc, pending: &PendingState) { let bounds = self.toplevel.get().and_then(|tl| tl.tl_render_bounds()); let pos = self.buffer_abs_pos.get(); let apply_damage = |pos: Rect| { @@ -1564,7 +1564,7 @@ impl WlSurface { if let Some(bounds) = bounds { damage = damage.intersect(bounds); } - self.client.state.damage(damage); + self.client.state.damage_for_surface(self, damage); } else { let matrix = self.damage_matrix.get(); if let Some(buffer) = self.buffer.get() { @@ -1577,7 +1577,7 @@ impl WlSurface { if let Some(bounds) = bounds { damage = damage.intersect(bounds); } - self.client.state.damage(damage); + self.client.state.damage_for_surface(self, damage); } } for damage in &pending.surface_damage { @@ -1590,7 +1590,7 @@ impl WlSurface { damage = Rect::new_saturating(x1, y1, x2, y2); } damage = damage.intersect(bounds.unwrap_or(pos)); - self.client.state.damage(damage); + self.client.state.damage_for_surface(self, damage); } } }; diff --git a/src/rect.rs b/src/rect.rs index 938c7b3f..2382b797 100644 --- a/src/rect.rs +++ b/src/rect.rs @@ -246,7 +246,6 @@ where dx * dx + dy * dy } - #[expect(dead_code)] pub fn contains_rect(&self, rect: &Rect) -> bool where U: Tag, diff --git a/src/renderer.rs b/src/renderer.rs index c3a97c2f..e905540d 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -346,7 +346,7 @@ impl Renderer<'_> { self.render_tl_aux(placeholder.tl_data(), bounds, true); } - fn render_tab_bar(&mut self, tab_bar: &TabBar, x: i32, y: i32, _container_width: i32) { + fn render_tab_bar(&mut self, tab_bar: &TabBar, x: i32, y: i32, container_width: i32) { let srgb_srgb = self.state.color_manager.srgb_gamma22(); let srgb = &srgb_srgb.linear; let perceptual = RenderIntent::Perceptual; @@ -355,100 +355,160 @@ impl Renderer<'_> { let radius = self.state.theme.sizes.tab_bar_radius.get(); let border_width = self.state.theme.sizes.tab_bar_border_width.get(); let text_padding = self.state.theme.sizes.tab_bar_text_padding.get(); + let padding = self.state.theme.sizes.tab_bar_padding.get(); let bar_height = tab_bar.height; let render_scale = tab_bar.render_scale; + let tab_opacity = self.state.theme.sizes.tab_opacity.get() as f32 / 100.0; + let render_text = self.state.theme.tab_render_text.get(); - // Vulkan sorts ops: Fill < RoundedFill (by z_order, color) < Tex/RoundedTex (by index). - // We use: - // FillRect – tiny strip for Vulkan paint regions (hidden) - // RoundedFillRect z0 – solid rounded bg - // RoundedFillRect z1 – rounded border ring (on top of bg) - // RoundedCopyTexture – title text (on top of everything) - for entry in &tab_bar.entries { - let (bg_color, border_color, _text_color) = TabBar::entry_colors(self.state, entry); - let ex = entry.x.get(); - let ew = entry.width.get(); - let tab_rect = Rect::new_sized_saturating(ex, 0, ew, bar_height); - let tab_cr = CornerRadius::from(radius as f32); + // Compute static positions for non-destroying entries. + let active_entries: Vec = tab_bar + .entries + .iter() + .enumerate() + .filter(|(_, e)| !e.destroying) + .map(|(i, _)| i) + .collect(); + let n = active_entries.len() as i32; + if n == 0 { + return; + } + let total_padding = padding * (n + 1); + let available = (container_width - total_padding).max(0); + let per_tab = available / n; + let mut remainder = available - per_tab * n; - // Tiny FillRect strip to establish Vulkan paint regions (visually hidden - // behind the RoundedFillRect bg that renders later). - let strip = Rect::new_sized_saturating( - ex + radius, - bar_height / 2, - (ew - 2 * radius).max(1), - 1, - ); - self.base - .fill_boxes2(slice::from_ref(&strip), &bg_color, srgb, perceptual, x, y); + // Build a map: entry index → (x, width) in pixels. + let mut positions: Vec<(i32, i32)> = vec![(0, 0); tab_bar.entries.len()]; + let mut tab_x = padding; + for &idx in &active_entries { + let w = if remainder > 0 { + remainder -= 1; + per_tab + 1 + } else { + per_tab + }; + positions[idx] = (tab_x, w); + tab_x += w + padding; + } - // Rounded solid bg fill (z_order=0, renders first among RoundedFill). - self.base.fill_rounded_rect_z( - tab_rect.move_(x, y), - &bg_color, - None, - srgb, - perceptual, - tab_cr.scaled_by(scalef), - 0.0, - 0, - ); + let render_entry = + |renderer: &mut Self, idx: usize, entry: &crate::tree::tab_bar::TabBarEntry| { + let fade = entry.fade_opacity.value() * tab_opacity; + if fade < 1e-4 { + return; + } - // Rounded border ring on top (z_order=1, renders after bg). - if border_width > 0 { - self.base.fill_rounded_rect_z( + let (ex, ew) = positions[idx]; + if ew < 1 { + return; + } + + let (bg_color, border_color, _text_color) = entry.blended_colors(renderer.state); + let bg_color = bg_color * fade; + let border_color = border_color * fade; + + let tab_rect = Rect::new_sized_saturating(ex, 0, ew, bar_height); + + // Per-tab blur backdrop. + let cfg = renderer.state.blur_config.get(); + if cfg.passes > 0 { + let blur_rect = tab_rect.move_(x, y); + let scaled = renderer.base.scale_rect(blur_rect); + renderer.base.push_blur_backdrop2( + scaled, + cfg.passes, + cfg.size, + None, + None, + 0, + radius as f32 * renderer.base.scalef as f32, + ); + } + let tab_cr = CornerRadius::from(radius as f32); + + renderer.base.fill_rounded_rect_z( tab_rect.move_(x, y), - &border_color, + &bg_color, None, srgb, perceptual, tab_cr.scaled_by(scalef), - border_width as f32 * scalef, - 1, + 0.0, + 0, ); - } - // Title text as RoundedCopyTexture (sorts after all RoundedFill). - let tex_ref = entry.title_texture.borrow(); - if let Some(tex) = tex_ref.as_ref() - && let Some(texture) = tex.texture() - { - use crate::theme::TabTitleAlign; - let (tw, _th) = texture.size(); - let tex_width = (tw as f64 / render_scale.to_f64()).round() as i32; - let tab_inner = ew - 2 * (text_padding + border_width); - let text_x = match self.state.theme.tab_title_align.get() { - TabTitleAlign::Start => x + ex + text_padding + border_width, - TabTitleAlign::Center => { - x + ex - + border_width - + (tab_inner.max(0) - tex_width).max(0) / 2 - + text_padding.min(tab_inner.max(0) / 2) - } - TabTitleAlign::End => { - let end_x = x + ex + ew - tex_width - text_padding - border_width; - end_x.max(x + ex + border_width) - } - }; - let (tx, ty) = self.base.scale_point(text_x, y); - self.base.render_rounded_texture( - &texture, - None, - tx, - ty, - None, - None, - render_scale, - None, - None, - AcquireSync::None, - ReleaseSync::None, - self.state.color_manager.srgb_gamma22(), - perceptual, - AlphaMode::PremultipliedElectrical, - CornerRadius::from(0.0_f32), - ); + if border_width > 0 { + renderer.base.fill_rounded_rect_z( + tab_rect.move_(x, y), + &border_color, + None, + srgb, + perceptual, + tab_cr.scaled_by(scalef), + border_width as f32 * scalef, + 1, + ); + } + + if !render_text { + return; + } + + let tex_ref = entry.title_texture.borrow(); + if let Some(tex) = tex_ref.as_ref() + && let Some(texture) = tex.texture() + { + use crate::theme::TabTitleAlign; + let (tw, _th) = texture.size(); + let tex_width = (tw as f64 / render_scale.to_f64()).round() as i32; + let tab_inner = ew - 2 * (text_padding + border_width); + let text_x = match renderer.state.theme.tab_title_align.get() { + TabTitleAlign::Start => x + ex + text_padding + border_width, + TabTitleAlign::Center => { + x + ex + + border_width + + (tab_inner.max(0) - tex_width).max(0) / 2 + + text_padding.min(tab_inner.max(0) / 2) + } + TabTitleAlign::End => { + let end_x = x + ex + ew - tex_width - text_padding - border_width; + end_x.max(x + ex + border_width) + } + }; + let (tx, ty) = renderer.base.scale_point(text_x, y); + renderer.base.render_rounded_texture( + &texture, + None, + tx, + ty, + None, + None, + render_scale, + None, + None, + AcquireSync::None, + ReleaseSync::None, + renderer.state.color_manager.srgb_gamma22(), + perceptual, + AlphaMode::PremultipliedElectrical, + CornerRadius::from(0.0_f32), + ); + } + }; + + // Render unfocused entries first, then focused on top. + for (idx, entry) in tab_bar.entries.iter().enumerate() { + if entry.destroying || entry.focused_anim.target() >= 0.5 { + continue; } + render_entry(self, idx, entry); + } + for (idx, entry) in tab_bar.entries.iter().enumerate() { + if entry.destroying || entry.focused_anim.target() < 0.5 { + continue; + } + render_entry(self, idx, entry); } } @@ -466,7 +526,6 @@ impl Renderer<'_> { self.render_container_decorations(container, x, y); if let Some(child) = container.mono_child.get() { - // Render tab bar if present. { let tab_bar = container.tab_bar.borrow(); if let Some(tb) = tab_bar.as_ref() { diff --git a/src/renderer/renderer_base.rs b/src/renderer/renderer_base.rs index 467e25e8..06751e4c 100644 --- a/src/renderer/renderer_base.rs +++ b/src/renderer/renderer_base.rs @@ -411,6 +411,19 @@ impl RendererBase<'_> { mask: Option, cache: Option>>>, cache_epoch: u64, + ) { + self.push_blur_backdrop2(rect, passes, offset, mask, cache, cache_epoch, 0.0); + } + + pub fn push_blur_backdrop2( + &mut self, + rect: Rect, + passes: u8, + offset: f32, + mask: Option, + cache: Option>>>, + cache_epoch: u64, + corner_radius: f32, ) { let target = FramebufferRect::new( rect.x1() as f32, @@ -422,6 +435,10 @@ impl RendererBase<'_> { self.fb_height, ); let cache_pixel_rect = [rect.x1(), rect.y1(), rect.x2(), rect.y2()]; + let pixel_size = [ + (rect.x2() - rect.x1()) as f32, + (rect.y2() - rect.y1()) as f32, + ]; self.ops.push(GfxApiOpt::BlurBackdrop(BlurBackdrop { rect: target, passes, @@ -430,6 +447,8 @@ impl RendererBase<'_> { cache, cache_epoch, cache_pixel_rect, + corner_radius, + pixel_size, })); } } diff --git a/src/state.rs b/src/state.rs index 136597d1..7b3d2be2 100644 --- a/src/state.rs +++ b/src/state.rs @@ -306,10 +306,10 @@ pub struct State { pub hyprland_global_shortcuts: CopyHashMap<(String, String), Rc>, pub layer_rules: RefCell>, pub blur_config: Cell, - pub blur_cache_epoch: NumCell, pub animations_config: Cell, pub active_animations: RefCell>>, pub close_snapshots: RefCell>>, + pub tab_animation_containers: RefCell>>, } // impl Drop for State { @@ -1046,10 +1046,25 @@ impl State { } pub fn damage(&self, rect: Rect) { - self.damage2(false, false, rect); + self.damage2(false, false, None, rect); } - pub fn damage2(&self, cursor: bool, skip_hc: bool, rect: Rect) { + /// Damage caused by `source` (or any of its subsurface descendants). The + /// source's blur-cache will not be invalidated for this damage, since a + /// surface's own contents don't sit behind its blur backdrop. Other + /// blurring surfaces are still invalidated normally. + pub fn damage_for_surface(&self, source: &Rc, rect: Rect) { + let root_id = source.get_root().node_id; + self.damage2(false, false, Some(root_id), rect); + } + + pub fn damage2( + &self, + cursor: bool, + skip_hc: bool, + source_root: Option, + rect: Rect, + ) { if rect.is_empty() { return; } @@ -1059,11 +1074,18 @@ impl State { for surface in layer.iter() { if surface.blur.get() && surface.node_absolute_position().intersects(&rect) { - surface.blur_cache_epoch.fetch_add(1); + // Skip invalidation when the damage comes from the + // bar itself (or its subsurfaces) — the bar's own + // pixels aren't part of its own blur backdrop. + if source_root != Some(surface.surface.node_id) { + surface.blur_cache_epoch.fetch_add(1); + } } if surface.blur.get() && surface.blur_popups.get() { surface.for_each_popup(|popup| { - if popup.node_absolute_position().intersects(&rect) { + if popup.node_absolute_position().intersects(&rect) + && source_root != Some(popup.xdg.surface.node_id) + { popup.blur_cache_epoch.fetch_add(1); } }); @@ -1518,23 +1540,31 @@ impl State { } } let mut snapshots = self.close_snapshots.borrow_mut(); - if snapshots.is_empty() { - return; - } - snapshots.retain(|snap| { - if snap.close_progress(self).is_none() { - // Final damage so the snapshot's last-rendered position gets - // repainted (clearing any leftover pixels). + if !snapshots.is_empty() { + snapshots.retain(|snap| { + if snap.close_progress(self).is_none() { + if let Some(output) = snap.output.upgrade() { + self.damage(output.global.pos.get()); + } + return false; + } if let Some(output) = snap.output.upgrade() { self.damage(output.global.pos.get()); } - return false; + true + }); + } + { + let mut tab_containers = self.tab_animation_containers.borrow_mut(); + if !tab_containers.is_empty() { + tab_containers.retain(|weak| { + let Some(container) = weak.upgrade() else { + return false; + }; + container.tick_tab_animations() + }); } - if let Some(output) = snap.output.upgrade() { - self.damage(output.global.pos.get()); - } - true - }); + } } pub fn output_extents_changed(&self) { diff --git a/src/theme.rs b/src/theme.rs index d29138c8..7c9945fe 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -183,6 +183,14 @@ impl Color { self.a >= 1.0 } + pub fn alpha(&self) -> f32 { + self.a + } + + pub fn set_alpha(&mut self, a: f32) { + self.a = a; + } + pub fn from_gray_srgb(g: u8) -> Self { Self::from_srgb(g, g, g) } @@ -456,10 +464,16 @@ colors! { highlight = (0x9d, 0x28, 0xc6, 0x7f), tab_active_background = (0x4c, 0x78, 0x99), tab_active_border = (0x28, 0x55, 0x77), + tab_focused_background = (0x3a, 0x5a, 0x70), + tab_focused_border = (0x28, 0x55, 0x77), tab_inactive_background = (0x22, 0x22, 0x22), tab_inactive_border = (0x33, 0x33, 0x33), + tab_urgent_background = (0x23, 0x09, 0x2c), + tab_urgent_border = (0x44, 0x11, 0x55), tab_active_text = (0xff, 0xff, 0xff), + tab_focused_text = (0xdd, 0xdd, 0xdd), tab_inactive_text = (0x88, 0x88, 0x88), + tab_urgent_text = (0xff, 0xff, 0xff), tab_bar_background = (0x00, 0x00, 0x00, 0x00), tab_attention_background = (0x23, 0x09, 0x2c), } @@ -486,10 +500,16 @@ impl StaticText for ThemeColor { ThemeColor::highlight => "Highlight", ThemeColor::tab_active_background => "Tab Background (active)", ThemeColor::tab_active_border => "Tab Border (active)", + ThemeColor::tab_focused_background => "Tab Background (focused)", + ThemeColor::tab_focused_border => "Tab Border (focused)", ThemeColor::tab_inactive_background => "Tab Background (inactive)", ThemeColor::tab_inactive_border => "Tab Border (inactive)", + ThemeColor::tab_urgent_background => "Tab Background (urgent)", + ThemeColor::tab_urgent_border => "Tab Border (urgent)", ThemeColor::tab_active_text => "Tab Text (active)", + ThemeColor::tab_focused_text => "Tab Text (focused)", ThemeColor::tab_inactive_text => "Tab Text (inactive)", + ThemeColor::tab_urgent_text => "Tab Text (urgent)", ThemeColor::tab_bar_background => "Tab Bar Background", ThemeColor::tab_attention_background => "Tab Attention Background", } @@ -610,6 +630,7 @@ sizes! { tab_bar_border_width = (0, 1000, 2), tab_bar_text_padding = (0, 1000, 4), tab_bar_gap = (0, 1000, 4), + tab_opacity = (0, 100, 100), } impl StaticText for ThemeSized { @@ -627,6 +648,7 @@ impl StaticText for ThemeSized { ThemeSized::tab_bar_border_width => "Tab Bar Border Width", ThemeSized::tab_bar_text_padding => "Tab Bar Text Padding", ThemeSized::tab_bar_gap => "Tab Bar Gap", + ThemeSized::tab_opacity => "Tab Opacity", } } } @@ -784,6 +806,8 @@ pub struct Theme { pub corner_radius: Cell, pub autotile_enabled: Cell, pub tab_title_align: Cell, + pub tab_from_top: Cell, + pub tab_render_text: Cell, } impl Default for Theme { @@ -802,6 +826,8 @@ impl Default for Theme { corner_radius: Cell::new(CornerRadius::default()), autotile_enabled: Cell::new(false), tab_title_align: Cell::new(TabTitleAlign::default()), + tab_from_top: Cell::new(false), + tab_render_text: Cell::new(true), } } } diff --git a/src/tree/container.rs b/src/tree/container.rs index afd87e03..e9c5be51 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -18,10 +18,7 @@ use { ContainingNode, Direction, FindTreeResult, FindTreeUsecase, FloatNode, FoundNode, Node, NodeId, NodeLayerLink, NodeLocation, OutputNode, TddType, TileDragDestination, ToplevelData, ToplevelNode, ToplevelNodeBase, ToplevelType, WorkspaceNode, - default_tile_drag_bounds, - tab_bar::{TabBar, TabBarEntry}, - toplevel_set_workspace, - walker::NodeVisitor, + default_tile_drag_bounds, tab_bar::TabBar, toplevel_set_workspace, walker::NodeVisitor, }, utils::{ clonecell::CloneCell, @@ -265,6 +262,9 @@ impl ContainerNode { update_tab_textures_scheduled: Cell::new(false), ephemeral: Cell::new(Ephemeral::Off), }); + child_node_ref + .focus_history + .set(Some(slf.focus_history.add_last(child_node_ref.clone()))); child.tl_set_parent(slf.clone()); slf.pull_child_properties(&child_node_ref); slf @@ -700,6 +700,32 @@ impl ContainerNode { } } + fn register_tab_animation(self: &Rc) { + let mut list = self.state.tab_animation_containers.borrow_mut(); + let already = list + .iter() + .any(|w| w.upgrade().map_or(false, |c| Rc::ptr_eq(&c, self))); + if !already { + list.push(Rc::downgrade(self)); + } + } + + pub fn tick_tab_animations(&self) -> bool { + let now = self.state.now_nsec(); + let cfg = self.state.animations_config.get(); + let duration = if cfg.enabled { cfg.open_duration_ms } else { 0 }; + let mut bar = self.tab_bar.borrow_mut(); + if let Some(bar) = bar.as_mut() { + let animating = bar.tick(now, duration); + if animating { + self.damage(); + } + animating + } else { + false + } + } + fn compute_render_positions(&self) { self.compute_render_positions_scheduled.set(false); let mut rd = self.render_data.borrow_mut(); @@ -780,6 +806,7 @@ impl ContainerNode { if self.mono_child.is_some() == child.is_some() { return; } + let exiting_mono = self.mono_child.is_some() && child.is_none(); let child = { let children = self.child_nodes.borrow(); match child { @@ -813,6 +840,9 @@ impl ContainerNode { } else { for child in self.children.iter() { child.node.tl_set_visible(true); + if exiting_mono { + child.node.tl_data().start_open_animation(); + } } } } @@ -913,9 +943,10 @@ impl ContainerNode { "untitled".to_string() } - /// Rebuild the tab bar. If `override_id` and `override_title` are provided, - /// use the override title for that child instead of borrowing it (avoids - /// RefCell double-borrow when called from node_child_title_changed). + /// Update the tab bar entries to match the current children. Preserves + /// existing entries (and their animation state) for children still present, + /// starts exit animations for removed children, and creates new entries + /// with enter animations for added children. fn rebuild_tab_bar_with_override( self: &Rc, override_id: Option, @@ -938,24 +969,35 @@ impl ContainerNode { .persistent .scale .get(); - let mut bar = TabBar::new(height, render_scale); - for child in self.children.iter() { - let child_id = child.node.node_id(); - let title = self.get_child_tab_title(&child, override_id, override_title); - bar.entries.push(TabBarEntry { - child_id, - title, - title_texture: Rc::new(RefCell::new(None)), - active: child_id == active_id, - attention_requested: child.attention_requested.get(), - x: Cell::new(0), - width: Cell::new(0), - }); - } - let padding = self.state.theme.sizes.tab_bar_padding.get(); - bar.layout_entries(self.width.get(), padding); - *self.tab_bar.borrow_mut() = Some(bar); + let now = self.state.now_nsec(); + + let children_data: Vec<_> = self + .children + .iter() + .map(|child| { + let child_id = child.node.node_id(); + let title = self.get_child_tab_title(&child, override_id, override_title); + let active = child_id == active_id; + let focused = active && child.active.get(); + let urgent = child.attention_requested.get(); + (child_id, title, active, focused, urgent) + }) + .collect(); + + let mut bar_ref = self.tab_bar.borrow_mut(); + let bar = bar_ref.get_or_insert_with(|| TabBar::new(height, render_scale)); + bar.height = height; + bar.render_scale = render_scale; + bar.update_entries(&children_data, now); + bar.update_animations(); + let animating = bar.entries.iter().any(|e| e.is_animating()); + + drop(bar_ref); self.schedule_update_tab_textures(); + + if animating { + self.register_tab_animation(); + } } /// Wrap the focused child in a new sub-container with the given split direction. @@ -1421,8 +1463,11 @@ impl ContainerNode { let tab_bar = self.tab_bar.borrow(); if let Some(tb) = tab_bar.as_ref() { if seat_data.y >= 0 && seat_data.y < tb.height { - if let Some(idx) = tb.entry_at_x(seat_data.x) { - let child_id = tb.entries[idx].child_id; + let padding = self.state.theme.sizes.tab_bar_padding.get(); + if let Some(idx) = tb.entry_at_x(seat_data.x, self.width.get(), padding) { + let active_entries: Vec<_> = + tb.entries.iter().filter(|e| !e.destroying).collect(); + let child_id = active_entries[idx].child_id; drop(tab_bar); drop(seat_datas); let children = self.child_nodes.borrow(); @@ -1698,6 +1743,7 @@ impl ContainerNode { let entries: Vec<_> = tb .entries .iter() + .filter(|e| !e.destroying) .map(|e| { ( e.title.clone(), @@ -1722,7 +1768,6 @@ impl ContainerNode { if let Some(s) = scale { texture_height = (bar_height as f64 * s).round() as _; } - let mut scheduled = 0; let mut texture_refs = Vec::new(); for (title, (_, _, text_color), title_texture) in &entries { let mut tex_ref = title_texture.borrow_mut(); @@ -1737,7 +1782,6 @@ impl ContainerNode { scale, ); texture_refs.push(title_texture.clone()); - scheduled += 1; } (on_completed.event(), texture_refs) } @@ -2404,11 +2448,9 @@ impl ToplevelNodeBase for ContainerNode { size_changed |= self.height.replace(rect.height()) != rect.height(); if size_changed { self.update_content_size(); - // Re-layout tab bar entries when container size changes in mono mode. if self.mono_child.is_some() { - if let Some(bar) = self.tab_bar.borrow().as_ref() { - let padding = self.state.theme.sizes.tab_bar_padding.get(); - bar.layout_entries(rect.width(), padding); + if let Some(bar) = self.tab_bar.borrow_mut().as_mut() { + bar.update_animations(); } } // log::info!("tl_change_extents"); diff --git a/src/tree/tab_bar.rs b/src/tree/tab_bar.rs index 4dd1f485..6bc36c36 100644 --- a/src/tree/tab_bar.rs +++ b/src/tree/tab_bar.rs @@ -1,11 +1,85 @@ use { - crate::{scale::Scale, state::State, text::TextTexture, theme::Color, tree::NodeId}, - std::{ - cell::{Cell, RefCell}, - rc::Rc, + crate::{ + scale::Scale, + state::State, + text::TextTexture, + theme::{Color, Oklab}, + tree::NodeId, }, + std::{cell::RefCell, rc::Rc}, }; +/// A single animated float value that interpolates from `start` to `target` +/// over a configurable duration using an ease-out cubic curve. +#[derive(Clone, Debug)] +pub struct AnimatedF32 { + start: f32, + target: f32, + current: f32, + start_nsec: u64, + warped: bool, +} + +impl AnimatedF32 { + pub fn new(value: f32) -> Self { + Self { + start: value, + target: value, + current: value, + start_nsec: 0, + warped: true, + } + } + + pub fn set(&mut self, target: f32, now_nsec: u64) { + if (self.target - target).abs() < 1e-6 { + return; + } + self.start = self.current; + self.target = target; + self.start_nsec = now_nsec; + self.warped = false; + } + + pub fn warp(&mut self, value: f32) { + self.start = value; + self.target = value; + self.current = value; + self.warped = true; + } + + pub fn value(&self) -> f32 { + self.current + } + + pub fn target(&self) -> f32 { + self.target + } + + pub fn is_animating(&self) -> bool { + !self.warped && (self.current - self.target).abs() > 1e-5 + } + + pub fn tick(&mut self, now_nsec: u64, duration_ms: u32) { + if self.warped || duration_ms == 0 { + self.current = self.target; + self.warped = true; + return; + } + let elapsed = now_nsec.saturating_sub(self.start_nsec); + let dur = (duration_ms as u64).saturating_mul(1_000_000); + if elapsed >= dur { + self.current = self.target; + self.warped = true; + return; + } + let t = (elapsed as f32) / (dur as f32); + let inv = 1.0 - t; + let eased = 1.0 - inv * inv * inv; + self.current = self.start + (self.target - self.start) * eased; + } +} + /// A single entry (tab) within a tab bar. pub struct TabBarEntry { /// The node ID of the child this tab represents. @@ -14,19 +88,185 @@ pub struct TabBarEntry { pub title: String, /// Pre-rendered text texture for the tab title. pub title_texture: Rc>>, - /// Whether this is the active (visible) tab. - pub active: bool, - /// Whether this tab's window has requested attention. - pub attention_requested: bool, - /// X offset of this tab within the tab bar (relative to tab bar start). - pub x: Cell, - /// Width of this tab in pixels. - pub width: Cell, + /// Whether this entry is being destroyed (exit animation in flight). + pub destroying: bool, + + /// Horizontal offset as a fraction of total bar width (0.0–1.0). + pub offset: AnimatedF32, + /// Width as a fraction of total bar width (0.0–1.0). + pub width: AnimatedF32, + /// Vertical slide position: 0.0 = in place, 1.0 = fully slid out. + pub vertical_pos: AnimatedF32, + /// Fade opacity: 0.0 = invisible, 1.0 = fully visible. + pub fade_opacity: AnimatedF32, + /// Active (selected tab) blend weight: 0.0–1.0. + pub active_anim: AnimatedF32, + /// Focused (keyboard focus on this tab's container) blend weight: 0.0–1.0. + pub focused_anim: AnimatedF32, + /// Urgent/attention blend weight: 0.0–1.0. + pub urgent_anim: AnimatedF32, +} + +impl TabBarEntry { + pub fn new( + child_id: NodeId, + title: String, + active: bool, + focused: bool, + urgent: bool, + now_nsec: u64, + ) -> Self { + let mut entry = Self { + child_id, + title, + title_texture: Rc::new(RefCell::new(None)), + destroying: false, + offset: AnimatedF32::new(-1.0), + width: AnimatedF32::new(-1.0), + vertical_pos: AnimatedF32::new(1.0), + fade_opacity: AnimatedF32::new(0.0), + active_anim: AnimatedF32::new(if active { 1.0 } else { 0.0 }), + focused_anim: AnimatedF32::new(if focused { 1.0 } else { 0.0 }), + urgent_anim: AnimatedF32::new(if urgent { 1.0 } else { 0.0 }), + }; + entry.vertical_pos.set(0.0, now_nsec); + entry.fade_opacity.set(1.0, now_nsec); + entry + } + + pub fn begin_destroy(&mut self, now_nsec: u64) { + self.destroying = true; + self.vertical_pos.set(1.0, now_nsec); + self.fade_opacity.set(0.0, now_nsec); + } + + pub fn un_destroy(&mut self, now_nsec: u64) { + self.destroying = false; + self.vertical_pos.set(0.0, now_nsec); + self.fade_opacity.set(1.0, now_nsec); + } + + pub fn should_remove(&self) -> bool { + self.destroying && (!self.vertical_pos.is_animating() || self.width.value() < 1e-4) + } + + pub fn set_active(&mut self, active: bool, _now_nsec: u64) { + let target = if active { 1.0 } else { 0.0 }; + self.active_anim.warp(target); + } + + pub fn set_focused(&mut self, focused: bool, _now_nsec: u64) { + let target = if focused { 1.0 } else { 0.0 }; + self.focused_anim.warp(target); + } + + pub fn set_urgent(&mut self, urgent: bool, _now_nsec: u64) { + let u = if urgent && self.focused_anim.target() < 0.5 { + 1.0 + } else { + 0.0 + }; + self.urgent_anim.warp(u); + } + + pub fn set_title(&mut self, title: String) { + if self.title != title { + self.title = title; + *self.title_texture.borrow_mut() = None; + } + } + + pub fn tick_all(&mut self, now_nsec: u64, duration_ms: u32) { + self.offset.tick(now_nsec, duration_ms); + self.width.tick(now_nsec, duration_ms); + self.vertical_pos.tick(now_nsec, duration_ms); + self.fade_opacity.tick(now_nsec, duration_ms); + self.active_anim.tick(now_nsec, duration_ms); + self.focused_anim.tick(now_nsec, duration_ms); + self.urgent_anim.tick(now_nsec, duration_ms); + } + + pub fn is_animating(&self) -> bool { + self.offset.is_animating() + || self.width.is_animating() + || self.vertical_pos.is_animating() + || self.fade_opacity.is_animating() + || self.active_anim.is_animating() + || self.focused_anim.is_animating() + || self.urgent_anim.is_animating() + } + + /// Compute the blended color for this entry, interpolating in OkLab space + /// between active, focused, urgent, and inactive colors based on the + /// animated blend weights. + pub fn blended_colors(&self, state: &State) -> (Color, Color, Color) { + let theme = &state.theme; + + let active_v = self.active_anim.value(); + let urgent_v = (self.urgent_anim.value() - active_v).max(0.0); + let focused_v = (self.focused_anim.value() - active_v - urgent_v).max(0.0); + let inactive_v = (1.0 - active_v - urgent_v - focused_v).max(0.0); + + let bg = blend_colors_oklab(&[ + (active_v, theme.colors.tab_active_background.get()), + (focused_v, theme.colors.tab_focused_background.get()), + (urgent_v, theme.colors.tab_urgent_background.get()), + (inactive_v, theme.colors.tab_inactive_background.get()), + ]); + + let border = blend_colors_oklab(&[ + (active_v, theme.colors.tab_active_border.get()), + (focused_v, theme.colors.tab_focused_border.get()), + (urgent_v, theme.colors.tab_urgent_border.get()), + (inactive_v, theme.colors.tab_inactive_border.get()), + ]); + + let text = blend_colors_oklab(&[ + (active_v, theme.colors.tab_active_text.get()), + (focused_v, theme.colors.tab_focused_text.get()), + (urgent_v, theme.colors.tab_urgent_text.get()), + (inactive_v, theme.colors.tab_inactive_text.get()), + ]); + + (bg, border, text) + } +} + +fn blend_colors_oklab(weighted: &[(f32, Color)]) -> Color { + let mut total_weight = 0.0f32; + let mut lab = Oklab { + l: 0.0, + a: 0.0, + b: 0.0, + }; + let mut alpha = 0.0f32; + for &(w, color) in weighted { + if w < 1e-6 { + continue; + } + total_weight += w; + let c_lab = color.srgb_to_oklab(); + lab.l += w * c_lab.l; + lab.a += w * c_lab.a; + lab.b += w * c_lab.b; + alpha += w * color.alpha(); + } + if total_weight < 1e-6 { + return Color::TRANSPARENT; + } + let inv = 1.0 / total_weight; + lab.l *= inv; + lab.a *= inv; + lab.b *= inv; + alpha *= inv; + let mut c = lab.to_srgb(); + c.set_alpha(alpha); + c } /// A tab bar rendered above a container in mono (tabbed) mode. pub struct TabBar { - /// The individual tab entries. + /// The individual tab entries (persistent across updates). pub entries: Vec, /// Height of the tab bar in pixels (from theme). pub height: i32, @@ -35,7 +275,6 @@ pub struct TabBar { } impl TabBar { - /// Create a new empty tab bar. pub fn new(height: i32, render_scale: Scale) -> Self { Self { entries: Vec::new(), @@ -44,67 +283,116 @@ impl TabBar { } } - /// Recompute the positions and widths of all tab entries. - /// - /// `total_width` is the available width for the entire tab bar. - pub fn layout_entries(&self, total_width: i32, padding: i32) { - let n = self.entries.len() as i32; - if n == 0 { + /// Update entries to match the current set of children. Preserves existing + /// entries (and their animation state) for children that are still present, + /// starts exit animations for removed children, and creates new entries with + /// enter animations for added children. + pub fn update_entries( + &mut self, + children: &[(NodeId, String, bool, bool, bool)], + now_nsec: u64, + ) { + let mut old_entries: Vec = self.entries.drain(..).collect(); + + for &(child_id, ref title, active, focused, urgent) in children { + if let Some(pos) = old_entries.iter().position(|e| e.child_id == child_id) { + let mut entry = old_entries.remove(pos); + if entry.destroying { + entry.un_destroy(now_nsec); + } + entry.set_active(active, now_nsec); + entry.set_focused(focused, now_nsec); + entry.set_urgent(urgent, now_nsec); + entry.set_title(title.clone()); + self.entries.push(entry); + } else { + let mut entry = + TabBarEntry::new(child_id, title.clone(), active, focused, urgent, now_nsec); + entry.vertical_pos.warp(0.0); + entry.fade_opacity.warp(1.0); + self.entries.push(entry); + } + } + + for mut entry in old_entries { + if !entry.destroying { + entry.begin_destroy(now_nsec); + } + if entry.should_remove() { + continue; + } + self.entries.push(entry); + } + } + + /// Recompute offset/width for all entries. All values are warped + /// to their final positions immediately (no animation on position). + pub fn update_animations(&mut self) { + let active_count = self.entries.iter().filter(|e| !e.destroying).count(); + if active_count == 0 { return; } + let entry_width = 1.0 / active_count as f32; + let mut offset = 0.0f32; + + for entry in &mut self.entries { + if !entry.destroying { + entry.offset.warp(offset); + entry.width.warp(entry_width); + offset += entry_width; + } + } + } + + /// Tick all animations forward and remove completed exit animations. + /// Returns true if any animation is still in flight. + pub fn tick(&mut self, now_nsec: u64, duration_ms: u32) -> bool { + for entry in &mut self.entries { + entry.tick_all(now_nsec, duration_ms); + } + self.entries.retain(|e| !e.should_remove()); + self.is_animating() + } + + pub fn is_animating(&self) -> bool { + self.entries.iter().any(|e| e.is_animating()) + } + + /// Find the tab entry index at the given x coordinate (relative to tab bar). + /// Only considers non-destroying entries. + pub fn entry_at_x(&self, x: i32, total_width: i32, padding: i32) -> Option { + let active_entries: Vec<_> = self + .entries + .iter() + .enumerate() + .filter(|(_, e)| !e.destroying) + .collect(); + let n = active_entries.len() as i32; + if n == 0 { + return None; + } let total_padding = padding * (n + 1); let available = (total_width - total_padding).max(0); let per_tab = available / n; let mut remainder = available - per_tab * n; - let mut x = padding; - for entry in &self.entries { + let mut tab_x = padding; + for (i, (_idx, _entry)) in active_entries.iter().enumerate() { let w = if remainder > 0 { remainder -= 1; per_tab + 1 } else { per_tab }; - entry.x.set(x); - entry.width.set(w); - x += w + padding; - } - } - - /// Find the tab entry index at the given x coordinate (relative to tab bar). - /// - /// Returns `None` if the coordinate is in padding between tabs or out of bounds. - pub fn entry_at_x(&self, x: i32) -> Option { - for (i, entry) in self.entries.iter().enumerate() { - let ex = entry.x.get(); - let ew = entry.width.get(); - if x >= ex && x < ex + ew { + if x >= tab_x && x < tab_x + w { return Some(i); } + tab_x += w + padding; } None } - /// Get the colors for a tab entry based on its state. + /// Get bg, border, text colors. pub fn entry_colors(state: &State, entry: &TabBarEntry) -> (Color, Color, Color) { - let theme = &state.theme; - if entry.attention_requested { - ( - theme.colors.tab_attention_background.get(), - theme.colors.tab_inactive_border.get(), - theme.colors.tab_active_text.get(), - ) - } else if entry.active { - ( - theme.colors.tab_active_background.get(), - theme.colors.tab_active_border.get(), - theme.colors.tab_active_text.get(), - ) - } else { - ( - theme.colors.tab_inactive_background.get(), - theme.colors.tab_inactive_border.get(), - theme.colors.tab_inactive_text.get(), - ) - } + entry.blended_colors(state) } } diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 1e408803..8518fad5 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -241,10 +241,16 @@ pub struct Theme { pub corner_radius: Option, pub tab_active_bg_color: Option, pub tab_active_border_color: Option, + pub tab_focused_bg_color: Option, + pub tab_focused_border_color: Option, pub tab_inactive_bg_color: Option, pub tab_inactive_border_color: Option, + pub tab_urgent_bg_color: Option, + pub tab_urgent_border_color: Option, pub tab_active_text_color: Option, + pub tab_focused_text_color: Option, pub tab_inactive_text_color: Option, + pub tab_urgent_text_color: Option, pub tab_bar_bg_color: Option, pub tab_attention_bg_color: Option, pub tab_bar_height: Option, @@ -253,7 +259,10 @@ pub struct Theme { pub tab_bar_border_width: Option, pub tab_bar_text_padding: Option, pub tab_bar_gap: Option, + pub tab_opacity: Option, pub tab_title_align: Option, + pub tab_from_top: Option, + pub tab_render_text: Option, } #[derive(Debug, Clone)] @@ -398,6 +407,7 @@ pub enum AnimationCurve { Linear, EaseOut, EaseInOut, + #[allow(dead_code)] Bezier { x1: f32, y1: f32, x2: f32, y2: f32 }, } diff --git a/toml-config/src/config/parsers/theme.rs b/toml-config/src/config/parsers/theme.rs index 84f2d580..589005b1 100644 --- a/toml-config/src/config/parsers/theme.rs +++ b/toml-config/src/config/parsers/theme.rs @@ -105,41 +105,58 @@ impl Parser for ThemeParser<'_> { ( tab_active_bg_color, tab_active_border_color, + tab_focused_bg_color, + tab_focused_border_color, tab_inactive_bg_color, tab_inactive_border_color, + tab_urgent_bg_color, + tab_urgent_border_color, tab_active_text_color, + tab_focused_text_color, + ), + ( tab_inactive_text_color, + tab_urgent_text_color, tab_bar_bg_color, tab_attention_bg_color, tab_bar_height, tab_bar_padding, - ), - ( tab_bar_radius, tab_bar_border_width, tab_bar_text_padding, tab_bar_gap, - tab_title_align_val, ), + (tab_opacity, tab_title_align_val, tab_from_top, tab_render_text), ) = ext.extract(( ( opt(val("tab-active-bg-color")), opt(val("tab-active-border-color")), + opt(val("tab-focused-bg-color")), + opt(val("tab-focused-border-color")), opt(val("tab-inactive-bg-color")), opt(val("tab-inactive-border-color")), + opt(val("tab-urgent-bg-color")), + opt(val("tab-urgent-border-color")), opt(val("tab-active-text-color")), + opt(val("tab-focused-text-color")), + ), + ( opt(val("tab-inactive-text-color")), + opt(val("tab-urgent-text-color")), opt(val("tab-bar-bg-color")), opt(val("tab-attention-bg-color")), recover(opt(s32("tab-bar-height"))), recover(opt(s32("tab-bar-padding"))), - ), - ( recover(opt(s32("tab-bar-radius"))), recover(opt(s32("tab-bar-border-width"))), recover(opt(s32("tab-bar-text-padding"))), recover(opt(s32("tab-bar-gap"))), + ), + ( + recover(opt(s32("tab-opacity"))), recover(opt(str("tab-title-align"))), + recover(opt(bol("tab-from-top"))), + recover(opt(bol("tab-render-text"))), ), ))?; macro_rules! color { @@ -199,10 +216,16 @@ impl Parser for ThemeParser<'_> { corner_radius: corner_radius.map(|v| v.value as f32), tab_active_bg_color: color!(tab_active_bg_color), tab_active_border_color: color!(tab_active_border_color), + tab_focused_bg_color: color!(tab_focused_bg_color), + tab_focused_border_color: color!(tab_focused_border_color), tab_inactive_bg_color: color!(tab_inactive_bg_color), tab_inactive_border_color: color!(tab_inactive_border_color), + tab_urgent_bg_color: color!(tab_urgent_bg_color), + tab_urgent_border_color: color!(tab_urgent_border_color), tab_active_text_color: color!(tab_active_text_color), + tab_focused_text_color: color!(tab_focused_text_color), tab_inactive_text_color: color!(tab_inactive_text_color), + tab_urgent_text_color: color!(tab_urgent_text_color), tab_bar_bg_color: color!(tab_bar_bg_color), tab_attention_bg_color: color!(tab_attention_bg_color), tab_bar_height: tab_bar_height.despan(), @@ -211,7 +234,10 @@ impl Parser for ThemeParser<'_> { tab_bar_border_width: tab_bar_border_width.despan(), tab_bar_text_padding: tab_bar_text_padding.despan(), tab_bar_gap: tab_bar_gap.despan(), + tab_opacity: tab_opacity.despan(), tab_title_align: tab_title_align_val.map(|v| v.value.to_string()), + tab_from_top: tab_from_top.despan(), + tab_render_text: tab_render_text.despan(), }) } } diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index 49b85f66..1af0d7b7 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -14,9 +14,9 @@ use { crate::{ config::{ Action, AnimationCurve, AnimationsConfig, BlurConfig, ClientRule, Config, - ConfigConnector, ConfigDrmDevice, ConfigKeymap, - ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, LayerKind, LayerRule, Output, - OutputMatch, SimpleCommand, Status, Theme, WindowRule, parse_config, + ConfigConnector, ConfigDrmDevice, ConfigKeymap, ConnectorMatch, DrmDeviceMatch, Exec, + Input, InputMatch, LayerKind, LayerRule, Output, OutputMatch, SimpleCommand, Status, + Theme, WindowRule, parse_config, }, rules::{MatcherTemp, RuleMapper}, shortcuts::ModeState, @@ -47,8 +47,8 @@ use { set_color_management_enabled, set_corner_radius, set_default_workspace_capture, set_explicit_sync_enabled, set_float_above_fullscreen, set_floating_titles, set_idle, set_idle_grace_period, set_middle_click_paste_enabled, set_show_bar, - set_show_float_pin_icon, set_show_titles, set_tab_title_align, set_ui_drag_enabled, - set_ui_drag_threshold, + set_show_float_pin_icon, set_show_titles, set_tab_from_top, set_tab_render_text, + set_tab_title_align, set_ui_drag_enabled, set_ui_drag_threshold, status::{set_i3bar_separator, set_status, set_status_command, unset_status_command}, switch_to_vt, tasks::{self, JoinHandle}, @@ -1023,10 +1023,16 @@ impl State { color!(HIGHLIGHT_COLOR, highlight_color); color!(TAB_ACTIVE_BACKGROUND_COLOR, tab_active_bg_color); color!(TAB_ACTIVE_BORDER_COLOR, tab_active_border_color); + color!(TAB_FOCUSED_BACKGROUND_COLOR, tab_focused_bg_color); + color!(TAB_FOCUSED_BORDER_COLOR, tab_focused_border_color); color!(TAB_INACTIVE_BACKGROUND_COLOR, tab_inactive_bg_color); color!(TAB_INACTIVE_BORDER_COLOR, tab_inactive_border_color); + color!(TAB_URGENT_BACKGROUND_COLOR, tab_urgent_bg_color); + color!(TAB_URGENT_BORDER_COLOR, tab_urgent_border_color); color!(TAB_ACTIVE_TEXT_COLOR, tab_active_text_color); + color!(TAB_FOCUSED_TEXT_COLOR, tab_focused_text_color); color!(TAB_INACTIVE_TEXT_COLOR, tab_inactive_text_color); + color!(TAB_URGENT_TEXT_COLOR, tab_urgent_text_color); color!(TAB_BAR_BACKGROUND_COLOR, tab_bar_bg_color); color!(TAB_ATTENTION_BACKGROUND_COLOR, tab_attention_bg_color); macro_rules! size { @@ -1048,6 +1054,7 @@ impl State { size!(TAB_BAR_BORDER_WIDTH, tab_bar_border_width); size!(TAB_BAR_TEXT_PADDING, tab_bar_text_padding); size!(TAB_BAR_GAP, tab_bar_gap); + size!(TAB_OPACITY, tab_opacity); macro_rules! font { ($fun:ident, $field:ident) => { if let Some(font) = &theme.$field { @@ -1064,6 +1071,12 @@ impl State { if let Some(ref align) = theme.tab_title_align { set_tab_title_align(align); } + if let Some(from_top) = theme.tab_from_top { + set_tab_from_top(from_top); + } + if let Some(render_text) = theme.tab_render_text { + set_tab_render_text(render_text); + } } fn handle_switch_device(self: &Rc, dev: InputDevice, actions: &Rc) {