From 466da3da882232adb4523c7279712ceb5ffb050d Mon Sep 17 00:00:00 2001 From: atagen Date: Sun, 31 May 2026 18:11:58 +1000 Subject: [PATCH 01/10] it: fix integration geometry and tab scrolling --- src/ifs/wl_surface.rs | 19 ++++--- src/it/test_ifs/test_viewport.rs | 20 +++++++ src/it/tests/t0002_window.rs | 23 +++----- src/it/tests/t0003_multi_window.rs | 21 ++++++-- .../tests/t0007_subsurface/screenshot_1.qoi | Bin 8141 -> 8134 bytes .../tests/t0007_subsurface/screenshot_2.qoi | Bin 7832 -> 7825 bytes src/it/tests/t0014_container_scroll_focus.rs | 19 ++++--- src/it/tests/t0015_scroll_partial.rs | 18 ++++--- .../t0020_surface_offset/screenshot_1.qoi | Bin 8141 -> 8138 bytes .../t0020_surface_offset/screenshot_2.qoi | Bin 8141 -> 8138 bytes .../t0023_xdg_activation/screenshot_1.qoi | Bin 7925 -> 7849 bytes .../t0026_output_transform/screenshot_1.qoi | Bin 7835 -> 7832 bytes .../screenshot_1.qoi | Bin 11228 -> 11083 bytes .../screenshot_2.qoi | Bin 11228 -> 11083 bytes .../t0029_double_click_float/screenshot_1.qoi | Bin 7834 -> 9988 bytes .../t0029_double_click_float/screenshot_2.qoi | Bin 10118 -> 9988 bytes .../t0037_toplevel_drag/screenshot_2.qoi | Bin 7834 -> 7831 bytes .../screenshot_1.qoi | Bin 8139 -> 8136 bytes .../screenshot_2.qoi | Bin 8143 -> 8140 bytes .../t0039_alpha_modifier/screenshot_1.qoi | Bin 8142 -> 8140 bytes .../t0039_alpha_modifier/screenshot_2.qoi | Bin 8145 -> 8143 bytes .../tests/t0041_input_method/screenshot_1.qoi | Bin 7831 -> 7828 bytes .../tests/t0041_input_method/screenshot_2.qoi | Bin 8141 -> 8138 bytes .../tests/t0041_input_method/screenshot_3.qoi | Bin 7831 -> 7828 bytes .../t0042_toplevel_select/screenshot_1.qoi | Bin 10802 -> 10799 bytes .../t0042_toplevel_select/screenshot_2.qoi | Bin 10800 -> 10799 bytes .../t0042_toplevel_select/screenshot_3.qoi | Bin 10800 -> 10799 bytes .../t0042_toplevel_select/screenshot_4.qoi | Bin 9671 -> 9634 bytes src/it/tests/t0047_surface_damage.rs | 24 +++++---- src/tree/container.rs | 51 ++++++++++++------ 30 files changed, 123 insertions(+), 72 deletions(-) diff --git a/src/ifs/wl_surface.rs b/src/ifs/wl_surface.rs index 547b7e2a..4224e727 100644 --- a/src/ifs/wl_surface.rs +++ b/src/ifs/wl_surface.rs @@ -1520,25 +1520,25 @@ impl WlSurface { let bounds = self.toplevel.get().and_then(|tl| tl.tl_render_bounds()); let pos = self.buffer_abs_pos.get(); let apply_damage = |pos: Rect| { - if pending.damage_full { - let mut damage = pos; + let clip_damage = |mut damage: Rect| { + damage = damage.intersect(pos); if let Some(bounds) = bounds { damage = damage.intersect(bounds); } - self.client.state.damage(damage); + damage + }; + if pending.damage_full { + self.client.state.damage(clip_damage(pos)); } else { let matrix = self.damage_matrix.get(); if let Some(buffer) = self.buffer.get() { for damage in &pending.buffer_damage { - let mut damage = matrix.apply( + let damage = matrix.apply( pos.x1(), pos.y1(), damage.intersect(buffer.buffer.buf.rect), ); - if let Some(bounds) = bounds { - damage = damage.intersect(bounds); - } - self.client.state.damage(damage); + self.client.state.damage(clip_damage(damage)); } } for damage in &pending.surface_damage { @@ -1550,8 +1550,7 @@ impl WlSurface { let y2 = (damage.y2() + scale - 1) / scale; damage = Rect::new_saturating(x1, y1, x2, y2); } - damage = damage.intersect(bounds.unwrap_or(pos)); - self.client.state.damage(damage); + self.client.state.damage(clip_damage(damage)); } } }; diff --git a/src/it/test_ifs/test_viewport.rs b/src/it/test_ifs/test_viewport.rs index b25105c8..e08266de 100644 --- a/src/it/test_ifs/test_viewport.rs +++ b/src/it/test_ifs/test_viewport.rs @@ -29,6 +29,17 @@ impl TestViewport { Ok(()) } + pub fn unset_source(&self) -> Result<(), TestError> { + self.tran.send(SetSource { + self_id: self.id, + x: Fixed::from_int(-1), + y: Fixed::from_int(-1), + width: Fixed::from_int(-1), + height: Fixed::from_int(-1), + })?; + Ok(()) + } + pub fn set_destination(&self, width: i32, height: i32) -> Result<(), TestError> { self.tran.send(SetDestination { self_id: self.id, @@ -37,6 +48,15 @@ impl TestViewport { })?; Ok(()) } + + pub fn unset_destination(&self) -> Result<(), TestError> { + self.tran.send(SetDestination { + self_id: self.id, + width: -1, + height: -1, + })?; + Ok(()) + } } impl Drop for TestViewport { diff --git a/src/it/tests/t0002_window.rs b/src/it/tests/t0002_window.rs index 84571c57..28ee359f 100644 --- a/src/it/tests/t0002_window.rs +++ b/src/it/tests/t0002_window.rs @@ -1,7 +1,6 @@ use { crate::{ it::{test_error::TestError, testrun::TestRun}, - rect::Rect, tree::Node, }, std::rc::Rc, @@ -11,29 +10,19 @@ testcase!(); /// Create and map a single surface async fn test(run: Rc) -> Result<(), TestError> { - run.backend.install_default()?; + let ds = run.create_default_setup().await?; let client = run.create_client().await?; let window = client.create_window().await?; window.map().await?; - tassert_eq!(window.tl.core.width.get(), 800); - tassert_eq!( - window.tl.core.height.get(), - 600 - 2 * run.state.theme.title_plus_underline_height() - ); + let workspace_rect = ds.output.workspace_rect.get(); - tassert_eq!( - window.tl.server.node_absolute_position(), - Rect::new_sized( - 0, - 2 * run.state.theme.title_plus_underline_height(), - window.tl.core.width.get(), - window.tl.core.height.get(), - ) - .unwrap() - ); + tassert_eq!(window.tl.core.width.get(), workspace_rect.width()); + tassert_eq!(window.tl.core.height.get(), workspace_rect.height()); + + tassert_eq!(window.tl.server.node_absolute_position(), workspace_rect); Ok(()) } diff --git a/src/it/tests/t0003_multi_window.rs b/src/it/tests/t0003_multi_window.rs index 3fbf599c..db726f90 100644 --- a/src/it/tests/t0003_multi_window.rs +++ b/src/it/tests/t0003_multi_window.rs @@ -11,7 +11,7 @@ testcase!(); /// Create and map two surfaces async fn test(run: Rc) -> Result<(), TestError> { - run.backend.install_default()?; + let ds = run.create_default_setup().await?; let client = run.create_client().await?; @@ -21,17 +21,30 @@ async fn test(run: Rc) -> Result<(), TestError> { let window2 = client.create_window().await?; window2.map().await?; - let otop = 2 * run.state.theme.title_plus_underline_height(); + let workspace_rect = ds.output.workspace_rect.get(); let bw = run.state.theme.sizes.border_width.get(); + let child_width = (workspace_rect.width() - bw) / 2; tassert_eq!( window.tl.server.node_absolute_position(), - Rect::new_sized(0, otop, (800 - bw) / 2, 600 - otop).unwrap() + Rect::new_sized( + workspace_rect.x1(), + workspace_rect.y1(), + child_width, + workspace_rect.height(), + ) + .unwrap() ); tassert_eq!( window2.tl.server.node_absolute_position(), - Rect::new_sized((800 - bw) / 2 + bw, otop, (800 - bw) / 2, 600 - otop).unwrap() + Rect::new_sized( + workspace_rect.x1() + child_width + bw, + workspace_rect.y1(), + child_width, + workspace_rect.height(), + ) + .unwrap() ); Ok(()) diff --git a/src/it/tests/t0007_subsurface/screenshot_1.qoi b/src/it/tests/t0007_subsurface/screenshot_1.qoi index 230c0408f7411f5ac77c386a080c686f2646c3c0..b5954651730842344f9fa6b0313a8e3495e2b43f 100644 GIT binary patch delta 26 dcmX?Wf6RV^5#we>rp2<89hhElzG8p?MgV{*2Rr}( delta 35 ncmX?Rf7X735hKe#B_*ZFj*PdNuWanttjf4Smh%<^1TX>s>&ptK diff --git a/src/it/tests/t0007_subsurface/screenshot_2.qoi b/src/it/tests/t0007_subsurface/screenshot_2.qoi index 722271f61949f939e280e154ee9a514d0b14314c..718d5c298a2d70f46b17a4027f647d31295d9c97 100644 GIT binary patch delta 26 dcmbPXJJEK75#we>raYO+4oojNUob!bBLH#!29^K- delta 35 mcmbPeJHvK^5hKe#B_*ZFj*PdNuWanttjbs|!+C=N0vG|>nhF5` diff --git a/src/it/tests/t0014_container_scroll_focus.rs b/src/it/tests/t0014_container_scroll_focus.rs index 0186cbaf..dccd1096 100644 --- a/src/it/tests/t0014_container_scroll_focus.rs +++ b/src/it/tests/t0014_container_scroll_focus.rs @@ -48,13 +48,18 @@ async fn test(run: Rc) -> TestResult { let mono_container = w_mono2.tl.container_parent()?; let container_pos = mono_container.tl_data().pos.get(); - let w_mono1_title = mono_container.render_data.borrow_mut().title_rects[0] - .move_(container_pos.x1(), container_pos.y1()); - ds.mouse.abs( - &ds.connector, - w_mono1_title.x1() as _, - w_mono1_title.y1() as _, - ); + let (tab_x, tab_y) = { + let tab_bar = mono_container.tab_bar.borrow(); + let Some(tab_bar) = tab_bar.as_ref() else { + bail!("no tab bar"); + }; + let w_mono1_title = &tab_bar.entries[0]; + ( + container_pos.x1() + w_mono1_title.x.get() + w_mono1_title.width.get() / 2, + container_pos.y1() + tab_bar.height / 2, + ) + }; + ds.mouse.abs(&ds.connector, tab_x as _, tab_y as _); client.sync().await; tassert!(enters.next().is_err()); diff --git a/src/it/tests/t0015_scroll_partial.rs b/src/it/tests/t0015_scroll_partial.rs index c6cf49b7..f5cb6e3c 100644 --- a/src/it/tests/t0015_scroll_partial.rs +++ b/src/it/tests/t0015_scroll_partial.rs @@ -26,12 +26,18 @@ async fn test(run: Rc) -> TestResult { let container = w_mono2.tl.container_parent()?; let pos = container.tl_data().pos.get(); - let w_mono1_title = container.render_data.borrow_mut().title_rects[0].move_(pos.x1(), pos.y1()); - ds.mouse.abs( - &ds.connector, - w_mono1_title.x1() as f64, - w_mono1_title.y1() as f64, - ); + let (tab_x, tab_y) = { + let tab_bar = container.tab_bar.borrow(); + let Some(tab_bar) = tab_bar.as_ref() else { + bail!("no tab bar"); + }; + let w_mono1_title = &tab_bar.entries[0]; + ( + pos.x1() + w_mono1_title.x.get() + w_mono1_title.width.get() / 2, + pos.y1() + tab_bar.height / 2, + ) + }; + ds.mouse.abs(&ds.connector, tab_x as f64, tab_y as f64); client.sync().await; let enters = dss.kb.enter.expect()?; diff --git a/src/it/tests/t0020_surface_offset/screenshot_1.qoi b/src/it/tests/t0020_surface_offset/screenshot_1.qoi index eef5f37a645f72caeef422b0da0ac30b6bbd7161..4c826f86d5ac9208c42f2383ea7cddf791b8efbe 100644 GIT binary patch delta 37 mcmX?Wf69J?DdT1ZCOf{#0gNS#7dFQWr1FE9oL3kifDr)hJqi*4 delta 38 qcmX?Qf7X73DI=r!WJku^OjnFHYcP8AF`n4`f2$ui= delta 41 mcmbPXJKJ``e@5}ijEqZo?-~7tffuHm85tkSa6Vvw07d}03KB^G diff --git a/src/it/tests/t0028_top_level_restacking/screenshot_1.qoi b/src/it/tests/t0028_top_level_restacking/screenshot_1.qoi index f7bf53bfb47e66ca0eb0cb80d5c67eab09895f17..9f5fca3ccec0e941a93041aab8fd687decd79b2d 100644 GIT binary patch literal 11083 zcmXTS&rD-rU{+vYV2WU7_@@zCe*PZ=1H)e=@KpS~DH8YZh~xh=Ha12MfN%d81SF9K zM%kkwFd71*A%GSFFaFuPdqKn=nLy}E(h!<>{-J;WVa4$+=|ga14jDlV8WsEdcQgz} z!vK^Uh)4%_#6b%G{)ObfchZN9{!$=x=pVy>XjAEyI3#aEjXY!kv1e5H@87?pVRA=& zG(r6R`*$=qjOGS#i94DbMsvexeK}e&{vE9t!R^G+igC1J9IY5fTLq)7g3(sNXsckf zRRC@jjCPkn!7w^_G}=Z6rGwGj015@#=7zryu#8huB>4C5B@81+#XvDR8ZM*hfV6Y~ nQVpBA0)-uEe2D5%ax?@+Ltr!nMnhmU1V%%EDj{%y0Rk8S?XmR| literal 11228 zcmXTS&rD-rU{+vYV2WU7_@@zCe*PZ=1H)e=@KpS~DH8YZh~xh=Ha12MfN%d81SF9K zM%kkwFd71*A%GSFFaFuPdqKn=nLy}E(h!<>{-J+LN=gt-x13S-9JXqb$K3AieUW#`cZF`6Jo6U1n4xFbDUjbdr- zj24fh#p7u4I9fc87LVXQ)@aY`@87?pxnVRnjOK>XqF}Tr01w2D_8Ug~4Up-f(TZ{8 zR*Zk2V04wq6#D!362@4>s2HfhF&ZudnGQfP@b@qA^XI7QA-qv?Gz3ONU^E0qLtr!n MhF=JLV}Jlg0J2guZvX%Q diff --git a/src/it/tests/t0028_top_level_restacking/screenshot_2.qoi b/src/it/tests/t0028_top_level_restacking/screenshot_2.qoi index b454acd32bb155d7ad0c8228081cd1b491d69a6a..aaf1b1084b90ad1cae0b4bdad7f0e3589fbb1644 100644 GIT binary patch literal 11083 zcmXTS&rD-rU{+vYV2WU7_@@zCe*PZ=1H)e=@KpS~DH8YZh~xh=Ha12MfN%d81SF9K zM%kkwFd71*A%GSFFT^1Nk4zx+C6f7v{{4rQ#ka%{!Hqd&1Tk<_?C;;vFc=L3P;MY0 z9sIL*_xk$)oSWW>BjqJ&$^`#0{Ac)kM;ek%Q4BU16$VAoXqbRP0aRj*CJ4;jHyS3R zVKQ1mjFu3$#1D-Y1*1j5Xi+d)6pR)Hquq(WfB%ja1*1j5Xi+d)6pR)HNP|_QZRF85 z@@U04S~1e1Vg!fk-$%$r44FcI|6Y{vmOQj$6`);AV`9U6Q6K42-aZ>A(N5 zY7Zr-u_+xDlRg9wgTGI}>GB;lgbw{vQd0VR2b$E8jXY#LDhx`rbPE%3qX#J_P_oBA zhW`xM!f0+7ak&8; z?SCI352leR^!M*2&@cp8H_C*V0ayr>w!i~cqv}B6F&b9%N(Z3O{`;5s`EykD5Z)*` Y8UmvsFd71*Aut*O!!HEBF+cz#0NCX-ZvX%Q diff --git a/src/it/tests/t0029_double_click_float/screenshot_1.qoi b/src/it/tests/t0029_double_click_float/screenshot_1.qoi index dd974ccffda932661558262ad0c65bd5f05f7bed..e08dc52581fd8eb9989908db258884c870eabefa 100644 GIT binary patch literal 9988 zcmXTS&rD-rU{+vYV2WU7_@@zCe*PZ=1H)e=@KpS~DH8YZh~xh=Ha12MfN%d81SF9K zM%kkwFd71*Aut*Oqai@s5O^mJG5?VXguX;F|IokxuEY9gL;}Pza2s15hZ8mItHhU^E?oLSQrh$W8~45FmHR9-@1c91Vfd5Eu=C(GVC7fzc44 LN(dZafB;4S<*jIf delta 63 ycmZqin`OJfl#x+)~z diff --git a/src/it/tests/t0029_double_click_float/screenshot_2.qoi b/src/it/tests/t0029_double_click_float/screenshot_2.qoi index f49edd4d3627d804802ad5f24047b69f68aebc11..e08dc52581fd8eb9989908db258884c870eabefa 100644 GIT binary patch literal 9988 zcmXTS&rD-rU{+vYV2WU7_@@zCe*PZ=1H)e=@KpS~DH8YZh~xh=Ha12MfN%d81SF9K zM%kkwFd71*Aut*Oqai@s5O^mJG5?VXguX;F|IokxuEY9gL;}Pza2s15hZ8mItHhU^E?oLSQrh$W8~45FmHR9-@1c91Vfd5Eu=C(GVC7fzc44 LN(dZafB;4S<*jIf literal 10118 zcmXTS&rD-rU{+vYV2WU7_@@zCe*PZ=1H)e=@KpS~DH8YZh~xh=Ha12MfN%d81SF9K zM%kkwFd71*Aut*OBPj%){j+!Xf_V6m3532R4WWtW9}$CVM$^G)Iv7m{ zpb!{M2cS?GEe}T1!Du=Fg}`V!0ENP6c`%v|M$-W(1V+;VC=^D^gOQjHKr#RKFY!|$ esOllSQF1f{MnhmU1V%$(Gz5lU2z+CJ07d}izNMo8 diff --git a/src/it/tests/t0037_toplevel_drag/screenshot_2.qoi b/src/it/tests/t0037_toplevel_drag/screenshot_2.qoi index b982600106353e9f8437aef8646a02393436a2f2..36c68e4e768d40bb17217fc1cab4cb1fd64c9431 100644 GIT binary patch delta 26 dcmbPbJKc7JDdT1Zrb3y?4oojNUob!bBLH(B2BQD~ delta 31 icmbPkJIi*1DI=r!WJku^OjnFHYcSTyaNb~m07d|tO$WLF diff --git a/src/it/tests/t0038_subsurface_parent_state/screenshot_1.qoi b/src/it/tests/t0038_subsurface_parent_state/screenshot_1.qoi index 988bc7671d6457cc816bad517a2afbcf0c715f82..e6f6db7440dae2befd5a205742e4fffcffbd4624 100644 GIT binary patch delta 26 dcmX?Yf5Lu)DdT1ZrbV)o3pieIK4X9YMgW4=2X6oX delta 31 icmX?Mf7*V7DI=r!IjAa diff --git a/src/it/tests/t0038_subsurface_parent_state/screenshot_2.qoi b/src/it/tests/t0038_subsurface_parent_state/screenshot_2.qoi index a750940444d66d00a431c783a04ab238c4e003f5..9abc8de3ef07210581c75e70ddaecda6139ed9e5 100644 GIT binary patch delta 26 dcmX?af5v`;DdT1Zre(5|9hhElzG8p?MgW0I2T1?` delta 31 icmX?Of8KtBDI=r!WJku^OjnFHYcOt><-ElJ0gM2u#t2{l diff --git a/src/it/tests/t0039_alpha_modifier/screenshot_1.qoi b/src/it/tests/t0039_alpha_modifier/screenshot_1.qoi index 8fe5d0b20afa62ec1d7d2d78e5404f23f866b3a7..80a29c84096cdf84b80c56efe5beec7ec389336a 100644 GIT binary patch delta 26 dcmX?Sf5v`;DdT1Zrlqoz3pieK9$ diff --git a/src/it/tests/t0041_input_method/screenshot_1.qoi b/src/it/tests/t0041_input_method/screenshot_1.qoi index d25fcf64e3e23045b297675595be3d4cd4e918fe..cd07ecd40de1de1351fc41f64764db24de49845f 100644 GIT binary patch delta 26 dcmbPkJH>W`DdT1ZraYO+223wFUob!bBLH!U28sXx delta 31 icmbPYJKc7JDI=r!WJku^OjnFHYcN*JaNb~m07d|sbqBEk diff --git a/src/it/tests/t0041_input_method/screenshot_2.qoi b/src/it/tests/t0041_input_method/screenshot_2.qoi index 7f93231a07c6dce95b1d4600a3bdcce74d3fb83a..d76ea9a00c0c4557432c1dcb10f2208b136f39a8 100644 GIT binary patch delta 26 dcmX?Wf69J?DdT1ZrX{kI4VYeVUSog&MgV_82Oj_c delta 31 icmX?Qf7X73DI=r!WJku^OjnFHYcOt<W`DdT1ZraYO+223wFUob!bBLH!U28sXx delta 31 icmbPYJKc7JDI=r!WJku^OjnFHYcN*JaNb~m07d|sbqBEk diff --git a/src/it/tests/t0042_toplevel_select/screenshot_1.qoi b/src/it/tests/t0042_toplevel_select/screenshot_1.qoi index 6423ef6db9291f5bd017c8ce96b8f9fadefd399e..6d57d140b27509b0ff6f1292276322b6a1f5e261 100644 GIT binary patch delta 33 mcmdlKvOZ*kDI@>CBH0Ol!QjR}dv~wR2bnU}H*e5rVgvv=(GkP| literal 10802 zcmXTS&rD-rU{+vYV2WU7_@@zCe*PZ=1H)e=@KpS~DH8YZh~xh=Ha12MfN$au-i?3u z?p}xfDJdyI`7m+mLs0cMMn(SKK!k=7G@PUl!C3!_WGBD{p{eW!@^=|iKT@pm*O z`~_v7(UdTn68?^+1Zb@=T04xE3E-qKT04xE3E+e|>mU^Ewu=7P~&Fq#WSbHQjX7|jKvxnMLGjOK#T PTripoNXZ2Z5Wolk7CT)9 diff --git a/src/it/tests/t0042_toplevel_select/screenshot_2.qoi b/src/it/tests/t0042_toplevel_select/screenshot_2.qoi index 823fd7503f7b28aec8d0b563df3c7e482a1b909f..478b3c4336b87cf8a13500c8c5b3f0f93d2b3fba 100644 GIT binary patch delta 44 scmdlGvOZ*kDWjn2Uog1w&)(hZ(7z(t37ZcxrK@iiV6I~1V1NKd00Qz6^#A|> literal 10800 zcmXTS&rD-rU{+vYV2WU7_@@zCe*PZ=1H)e=@KpS~DH8YZh~xh=Ha12MfN%XB5blkC z_U>MX#GxFhnj6xiB7gthzzhu|XgEn9g0Y~<>W1{8e?_trpwb9Io>7q-qoMKlhV-G) zl<;>nCHx&t3E%>8G$o9t1aLwaEffBZri9UyFj^jt=7P~&Fq#WSbHQjX7|jKvxnMLG zjOK#TTripoMsvYvE*Q-Pqq$%-7mVhD(OfW^3r2InXf7Dd1*5rOG#8BKg3(+snhQpA L0SUQ)0Rk8SQYl=x diff --git a/src/it/tests/t0042_toplevel_select/screenshot_3.qoi b/src/it/tests/t0042_toplevel_select/screenshot_3.qoi index 823fd7503f7b28aec8d0b563df3c7e482a1b909f..478b3c4336b87cf8a13500c8c5b3f0f93d2b3fba 100644 GIT binary patch delta 44 scmdlGvOZ*kDWjn2Uog1w&)(hZ(7z(t37ZcxrK@iiV6I~1V1NKd00Qz6^#A|> literal 10800 zcmXTS&rD-rU{+vYV2WU7_@@zCe*PZ=1H)e=@KpS~DH8YZh~xh=Ha12MfN%XB5blkC z_U>MX#GxFhnj6xiB7gthzzhu|XgEn9g0Y~<>W1{8e?_trpwb9Io>7q-qoMKlhV-G) zl<;>nCHx&t3E%>8G$o9t1aLwaEffBZri9UyFj^jt=7P~&Fq#WSbHQjX7|jKvxnMLG zjOK#TTripoMsvYvE*Q-Pqq$%-7mVhD(OfW^3r2InXf7Dd1*5rOG#8BKg3(+snhQpA L0SUQ)0Rk8SQYl=x diff --git a/src/it/tests/t0042_toplevel_select/screenshot_4.qoi b/src/it/tests/t0042_toplevel_select/screenshot_4.qoi index 714222f1611628b04d16f12865fff4e6bb6938c6..07dd87fbbc7a82e14db208be44aea30e480ffaf1 100644 GIT binary patch delta 34 ncmX@^y~ulmDI<^RUog1w&)(f@a~qR|^5%I=3XGHYsd54UJfjdq literal 9671 zcmXTS&rD-rU{+vYV2WU7_@@zCe*PZ=1H)e=@KpS~DH8YZh~xh=Ha12MfN%XB5blkC z_U>MX#GxFhnj6xiB7gthzzhu|XgEn9g0Y~<>W1_oC<~(E(HZJ%AGelo(A9qv-*f6u{JIdKgU);Di7rM$^M+dVnScFg2PU zM$-d0AwY@I^e~zpph*Etji!gu^Z-r>P+~MajHU-@QUFt<>0vZIfD;0g7)=kO=>eJ) zz|?4Z7)=l0ga9Q*)5B) -> TestResult { let output_damage = connector_data.damage.borrow(); tassert!(!output_damage.is_empty()); - // Buffer damage is transformed by the damage matrix which includes the surface position - // The buffer damage (0,0,1,1) should be transformed to surface coordinates - let expected_buffer_damage = buffer_damage.move_(surface_pos.x1(), surface_pos.y1()); + // The test window maps its 1x1 buffer through a viewport to the full window size. + let expected_buffer_damage = surface_pos; // Find the exact output damage that matches our expected buffer damage let mut found_exact_buffer_damage = false; @@ -331,10 +330,12 @@ async fn test(run: Rc) -> TestResult { // Test 7: Check output damage from existing window's viewport (which already has scaling) connector_data.damage.borrow_mut().clear(); - // The existing window was created with create_surface_ext() which automatically creates a viewport - // Let's verify that the viewport's existing scaling affects buffer damage correctly - // First, let's modify the viewport scaling that already exists on the window - window.surface.viewport.set_destination(150, 100)?; // Change scaling to 150x100 + // The existing window was created with create_surface_ext() which automatically creates a viewport. + // Commit the viewport size change separately; that commit intentionally damages the old/new extents. + window.surface.viewport.set_destination(150, 100)?; + window.surface.commit()?; + client.sync().await; + connector_data.damage.borrow_mut().clear(); // Add buffer damage to test viewport scaling coordinate transformation window.surface.damage_buffer(0, 0, 1, 1)?; // Damage entire 1x1 buffer @@ -346,8 +347,8 @@ async fn test(run: Rc) -> TestResult { let output_damage = connector_data.damage.borrow(); tassert!(!output_damage.is_empty()); - // With viewporter scaling, the 1x1 buffer damage should scale to 150x100 - // and be moved by surface position (0, 36) to get output coordinates (0, 36, 150, 136) + // With viewporter scaling, the 1x1 buffer damage should scale to the viewport destination. + let surface_pos = window.surface.server.buffer_abs_pos.get(); let expected_scaled_damage = Rect::new_sized(0, 0, 150, 100).unwrap(); let expected_output_damage = expected_scaled_damage.move_(surface_pos.x1(), surface_pos.y1()); @@ -402,8 +403,9 @@ async fn test(run: Rc) -> TestResult { rotation_window.map().await?; client.sync().await; - // Disable viewporter by setting destination to 0x0 to rely purely on buffer dimensions - rotation_window.surface.viewport.set_destination(0, 0)?; // Disable viewporter + // Disable viewporter to rely purely on buffer dimensions. + rotation_window.surface.viewport.unset_source()?; + rotation_window.surface.viewport.unset_destination()?; // Use a rectangular buffer (4x2) so rotation has a visible geometric effect // Attach AFTER mapping to avoid being overwritten by map()'s single-pixel buffer diff --git a/src/tree/container.rs b/src/tree/container.rs index b81f2e85..44a6a778 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -32,6 +32,7 @@ use { numcell::NumCell, on_drop_event::OnDropEvent, rc_eq::rc_eq, + scroller::Scroller, threshold_counter::ThresholdCounter, }, }, @@ -150,6 +151,7 @@ pub struct ContainerNode { pub child_removed: Rc, pub all_children_resized: Rc, pub tab_bar: RefCell>, + scroll: Scroller, pub update_tab_textures_scheduled: Cell, pub ephemeral: Cell, } @@ -266,6 +268,7 @@ impl ContainerNode { child_removed: state.lazy_event_sources.create_source(), all_children_resized: state.post_layout_event_sources.create_source(), tab_bar: RefCell::new(None), + scroll: Default::default(), update_tab_textures_scheduled: Cell::new(false), ephemeral: Cell::new(Ephemeral::Off), }); @@ -793,6 +796,18 @@ impl ContainerNode { self.activate_child2(child, false); } + fn activate_child_from_input( + self: &Rc, + child: &NodeRef, + seat: &Rc, + ) { + self.activate_child(child); + child + .node + .clone() + .node_do_focus(seat, Direction::Unspecified); + } + fn activate_child2(self: &Rc, child: &NodeRef, preserve_focus: bool) { if let Some(mc) = self.mono_child.get() { if mc.node.node_id() == child.node.node_id() { @@ -1519,7 +1534,7 @@ impl ContainerNode { fn button( self: Rc, id: CursorType, - _seat: &Rc, + seat: &Rc, _time_usec: u64, pressed: bool, button: u32, @@ -1549,7 +1564,7 @@ impl ContainerNode { if let Some(child) = children.get(&child_id) { let child_ref = child.to_ref(); drop(children); - self.activate_child(&child_ref); + self.activate_child_from_input(&child_ref, seat); } return; } @@ -2066,31 +2081,33 @@ impl Node for ContainerNode { self.button(id, seat, time_usec, state == ButtonState::Pressed, button); } - fn node_on_axis_event(self: Rc, _seat: &Rc, event: &PendingScroll) { + fn node_on_axis_event(self: Rc, seat: &Rc, event: &PendingScroll) { if self.mono_child.is_none() { return; } - // Use vertical scroll (index 1) to switch tabs. - let v = match event.v120[1].get() { - Some(v) if v != 0 => v, + let steps = match self.scroll.handle(event) { + Some(steps) => steps, _ => return, }; - let mono = match self.mono_child.get() { + let mut target = match self.mono_child.get() { Some(m) => m, None => return, }; - let next = if v > 0 { - // Scroll down → next tab. - mono.next().or_else(|| self.children.first()) - } else { - // Scroll up → previous tab. - mono.prev().or_else(|| self.children.last()) - }; - if let Some(next) = next { - if next.node.node_id() != mono.node.node_id() { - self.activate_child(&next); + let current_id = target.node.node_id(); + for _ in 0..steps.abs() { + let next = if steps > 0 { + target.next().or_else(|| self.children.first()) + } else { + target.prev().or_else(|| self.children.last()) + }; + match next { + Some(next) => target = next, + None => break, } } + if target.node.node_id() != current_id { + self.activate_child_from_input(&target, seat); + } } fn node_on_leave(&self, seat: &WlSeatGlobal) { From f22749db2f8ecacf58c60bea8c8a92d86f92fbf7 Mon Sep 17 00:00:00 2001 From: atagen Date: Sun, 31 May 2026 17:12:49 +1000 Subject: [PATCH 02/10] Restore focus after backend visibility resumes --- src/it/tests/t0022_toplevel_suspended.rs | 23 ++++++++++++-- src/tree/display.rs | 40 ++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/it/tests/t0022_toplevel_suspended.rs b/src/it/tests/t0022_toplevel_suspended.rs index 1fdacb1a..524856e3 100644 --- a/src/it/tests/t0022_toplevel_suspended.rs +++ b/src/it/tests/t0022_toplevel_suspended.rs @@ -2,7 +2,7 @@ use { crate::{ ifs::wl_surface::xdg_surface::xdg_toplevel::STATE_SUSPENDED, it::{ - test_error::TestResult, + test_error::{TestErrorExt, TestResult}, test_utils::{ test_ouput_node_ext::TestOutputNodeExt, test_toplevel_node_ext::TestToplevelNodeExt, }, @@ -10,7 +10,7 @@ use { }, }, isnt::std_1::collections::IsntHashSetExt, - std::rc::Rc, + std::{rc::Rc, time::Duration}, }; testcase!(); @@ -19,6 +19,7 @@ async fn test(run: Rc) -> TestResult { let ds = run.create_default_setup().await?; let client = run.create_client().await?; + let default_seat = client.get_default_seat().await?; let win1 = client.create_window().await?; win1.set_color(255, 0, 0, 255); @@ -44,5 +45,23 @@ async fn test(run: Rc) -> TestResult { client.sync().await; tassert!(win2.tl.core.states.borrow().not_contains(&STATE_SUSPENDED)); + let leaves = default_seat.kb.leave.expect()?; + let enters = default_seat.kb.enter.expect()?; + + run.cfg.set_idle(Duration::from_micros(100))?; + run.cfg.set_idle_grace_period(Duration::from_secs(0))?; + run.state.wheel.timeout(3).await?; + + client.sync().await; + tassert!(win2.tl.core.states.borrow().contains(&STATE_SUSPENDED)); + let leave = leaves.next().with_context(|| "no leave on suspend")?; + tassert_eq!(leave.surface, win2.surface.id); + + ds.mouse.rel(1.0, 1.0); + client.sync().await; + tassert!(win2.tl.core.states.borrow().not_contains(&STATE_SUSPENDED)); + let enter = enters.next().with_context(|| "no enter on restore")?; + tassert_eq!(enter.surface, win2.surface.id); + Ok(()) } diff --git a/src/tree/display.rs b/src/tree/display.rs index 440916bf..26b31a88 100644 --- a/src/tree/display.rs +++ b/src/tree/display.rs @@ -8,18 +8,25 @@ use { renderer::Renderer, state::State, tree::{ - FindTreeResult, FindTreeUsecase, FoundNode, Node, NodeId, NodeLayerLink, NodeLocation, - OutputNode, StackedNode, TileDragDestination, WorkspaceDragDestination, + Direction, FindTreeResult, FindTreeUsecase, FoundNode, Node, NodeId, NodeLayerLink, + NodeLocation, OutputNode, StackedNode, TileDragDestination, WorkspaceDragDestination, WorkspaceNodeId, walker::NodeVisitor, }, utils::{copyhashmap::CopyHashMap, linkedlist::LinkedList}, }, - std::{cell::Cell, ops::Deref, rc::Rc}, + std::{ + cell::{Cell, RefCell}, + mem, + ops::Deref, + rc::{Rc, Weak}, + }, }; pub struct DisplayNode { pub id: NodeId, pub extents: Cell, + visible: Cell, + suspend_restore_kb_foci: RefCell, Weak)>>, pub outputs: CopyHashMap>, pub stacked: Rc>>, pub stacked_above_layers: Rc>>, @@ -31,6 +38,8 @@ impl DisplayNode { let slf = Self { id, extents: Default::default(), + visible: Default::default(), + suspend_restore_kb_foci: Default::default(), outputs: Default::default(), stacked: Default::default(), stacked_above_layers: Default::default(), @@ -71,6 +80,17 @@ impl DisplayNode { pub fn update_visible(&self, state: &State) { let visible = state.root_visible(); + let was_visible = self.visible.replace(visible); + if !visible && was_visible { + let mut foci = self.suspend_restore_kb_foci.borrow_mut(); + foci.clear(); + for seat in state.globals.seats.lock().values() { + let node = seat.get_keyboard_node(); + if node.node_id() != self.id { + foci.push((seat.clone(), Rc::downgrade(&node))); + } + } + } for output in self.outputs.lock().values() { output.update_visible(); } @@ -82,6 +102,20 @@ impl DisplayNode { for seat in state.globals.seats.lock().values() { seat.set_visible(visible); } + if visible && !was_visible { + for (seat, node) in mem::take(&mut *self.suspend_restore_kb_foci.borrow_mut()) { + if seat.get_keyboard_node().node_id() == self.id { + if let Some(node) = node.upgrade() + && node.node_visible() + { + seat.focus_node(node); + } else { + seat.get_fallback_output() + .take_keyboard_navigation_focus(&seat, Direction::Unspecified); + } + } + } + } if visible { state.damage(self.extents.get()); } From 4c107130734bde3d33b75c9270a153a81df4767e Mon Sep 17 00:00:00 2001 From: atagen Date: Sun, 31 May 2026 17:09:23 +1000 Subject: [PATCH 03/10] Fix screencopy and portal capture state handling --- .../ext_image_copy_capture_frame_v1.rs | 23 +++++++-- .../ext_image_copy_capture_session_v1.rs | 5 ++ src/ifs/zwlr_screencopy_frame_v1.rs | 49 +++++++++++++------ src/ifs/zwlr_screencopy_manager_v1.rs | 4 +- src/portal/ptl_screencast.rs | 19 +++++-- src/tree/output.rs | 12 +++-- 6 files changed, 85 insertions(+), 27 deletions(-) diff --git a/src/ifs/ext_image_copy/ext_image_copy_capture_frame_v1.rs b/src/ifs/ext_image_copy/ext_image_copy_capture_frame_v1.rs index 48fa9038..786e5d47 100644 --- a/src/ifs/ext_image_copy/ext_image_copy_capture_frame_v1.rs +++ b/src/ifs/ext_image_copy/ext_image_copy_capture_frame_v1.rs @@ -86,9 +86,7 @@ impl ExtImageCopyCaptureFrameV1 { let buffer = self.session.buffer.get().unwrap(); if size != buffer.rect.size() { self.session.buffer_size_changed(); - // https://gitlab.freedesktop.org/wayland/wayland-protocols/-/issues/222 - // self.fail(FrameFailureReason::BufferConstraints); - // return; + return Err(FrameFailureReason::BufferConstraints); } if let Err(e) = buffer.update_framebuffer() { log::error!("Could not import buffer: {}", ErrorFmt(e)); @@ -102,6 +100,13 @@ impl ExtImageCopyCaptureFrameV1 { let mut shm_staging = self.session.shm_staging.take(); match storage { WlBufferStorage::Shm { mem, stride, .. } => { + log::debug!( + "ext-image-copy frame {:?} using wl_shm readback path: {}x{}, stride {}", + self.id, + buffer.rect.width(), + buffer.rect.height(), + *stride, + ); if let Some(b) = &shm_bridge && (b.physical_size() != buffer.rect.size() || b.format() != buffer.format @@ -159,6 +164,12 @@ impl ExtImageCopyCaptureFrameV1 { self.session.shm_staging.set(Some(staging)); } WlBufferStorage::Dmabuf { fb, .. } => { + log::debug!( + "ext-image-copy frame {:?} using dmabuf GPU copy path: {}x{}", + self.id, + buffer.rect.width(), + buffer.rect.height(), + ); let Some(fb) = fb else { return Err(FrameFailureReason::BufferConstraints); }; @@ -187,7 +198,11 @@ impl ExtImageCopyCaptureFrameV1 { ) { match self.try_copy(on, size, f) { Ok(()) => self.session.status.set(FrameStatus::Captured), - Err(e) => self.fail(e), + Err(e) => { + if self.session.status.get() != FrameStatus::Failed { + self.fail(e); + } + } } } diff --git a/src/ifs/ext_image_copy/ext_image_copy_capture_session_v1.rs b/src/ifs/ext_image_copy/ext_image_copy_capture_session_v1.rs index 286e9850..eb7154e7 100644 --- a/src/ifs/ext_image_copy/ext_image_copy_capture_session_v1.rs +++ b/src/ifs/ext_image_copy/ext_image_copy_capture_session_v1.rs @@ -83,6 +83,11 @@ impl ExtImageCopyCaptureSessionV1 { if self.size_debounce.replace(true) { return; } + if let Some(frame) = self.frame.get() + && let FrameStatus::Capturing | FrameStatus::Captured = self.status.get() + { + frame.fail(FrameFailureReason::BufferConstraints); + } self.force_capture.set(true); self.send_current_buffer_size(); self.send_done(); diff --git a/src/ifs/zwlr_screencopy_frame_v1.rs b/src/ifs/zwlr_screencopy_frame_v1.rs index d66035e9..d27b9c29 100644 --- a/src/ifs/zwlr_screencopy_frame_v1.rs +++ b/src/ifs/zwlr_screencopy_frame_v1.rs @@ -48,16 +48,13 @@ impl ZwlrScreencopyFrameV1 { } pub fn send_damage(&self) { - if let Some(output) = self.output.get() { - let pos = output.pos.get(); - self.client.event(Damage { - self_id: self.id, - x: 0, - y: 0, - width: pos.width() as _, - height: pos.height() as _, - }); - } + self.client.event(Damage { + self_id: self.id, + x: 0, + y: 0, + width: self.rect.width() as _, + height: self.rect.height() as _, + }); } pub fn send_buffer(&self) { @@ -111,10 +108,28 @@ impl ZwlrScreencopyFrameV1 { return Err(ZwlrScreencopyFrameV1Error::InvalidBufferFormat); } buffer.update_framebuffer()?; - if let Some(WlBufferStorage::Shm { stride, .. }) = buffer.storage.borrow_mut().deref() - && *stride != self.rect.width() * 4 - { - return Err(ZwlrScreencopyFrameV1Error::InvalidBufferStride); + match buffer.storage.borrow_mut().deref() { + Some(WlBufferStorage::Shm { stride, .. }) => { + if *stride != self.rect.width() * 4 { + return Err(ZwlrScreencopyFrameV1Error::InvalidBufferStride); + } + log::debug!( + "zwlr_screencopy frame {:?} using wl_shm readback path: {}x{}, stride {}", + self.id, + self.rect.width(), + self.rect.height(), + *stride, + ); + } + Some(WlBufferStorage::Dmabuf { .. }) => { + log::debug!( + "zwlr_screencopy frame {:?} using dmabuf GPU copy path: {}x{}", + self.id, + self.rect.width(), + self.rect.height(), + ); + } + _ => {} } self.buffer.set(Some(buffer)); if !with_damage && let Some(global) = self.output.get() { @@ -134,6 +149,12 @@ impl ZwlrScreencopyFrameV1 { } self.pending.take(); } + + pub fn cancel(&self) { + self.buffer.take(); + self.pending.take(); + self.send_failed(); + } } impl ZwlrScreencopyFrameV1RequestHandler for ZwlrScreencopyFrameV1 { diff --git a/src/ifs/zwlr_screencopy_manager_v1.rs b/src/ifs/zwlr_screencopy_manager_v1.rs index 2ff6c8a4..5f70072f 100644 --- a/src/ifs/zwlr_screencopy_manager_v1.rs +++ b/src/ifs/zwlr_screencopy_manager_v1.rs @@ -104,8 +104,8 @@ impl ZwlrScreencopyManagerV1 { let Some(global) = output.global.get() else { return Ok(()); }; - let mode = global.mode.get(); - let mut rect = Rect::new_sized_saturating(0, 0, mode.width, mode.height); + let (width, height) = global.pixel_size(); + let mut rect = Rect::new_sized_saturating(0, 0, width, height); if let Some(region) = region { let scale = global.persistent.scale.get().to_f64(); let x1 = (region.x1() as f64 * scale).round() as i32; diff --git a/src/portal/ptl_screencast.rs b/src/portal/ptl_screencast.rs index d3563f0f..20d21f4a 100644 --- a/src/portal/ptl_screencast.rs +++ b/src/portal/ptl_screencast.rs @@ -315,6 +315,14 @@ impl PwClientNodeOwner for StartedScreencast { } } } + log::debug!( + "Portal screencast using PipeWire dmabuf GPU copy path: {} buffers, format {}, modifier 0x{:08x}, size {}x{}", + self.buffers.borrow().len(), + self.format.get().name, + self.modifier.get(), + self.width.get(), + self.height.get(), + ); self.node .send_port_output_buffers(&self.port, &self.buffers.borrow()); } @@ -633,15 +641,18 @@ impl UsrJayScreencastOwner for StartedScreencast { fn ready(&self, ev: &Ready) { let idx = ev.idx as usize; - let buffers = &*self.buffers.borrow(); - let pbuffers = self.port.buffers.borrow(); - let buffer = &buffers[idx]; let discard_buffer = || { self.jay_screencast.release_buffer(idx); }; if !self.buffers_valid.get() { return; } + let buffers = self.buffers.borrow(); + let Some(buffer) = buffers.get(idx) else { + log::warn!("Ignoring ready event for unknown screencast buffer {idx}"); + return; + }; + let pbuffers = self.port.buffers.borrow(); let Some(io) = self.port.io_buffers.get() else { discard_buffer(); return; @@ -767,7 +778,7 @@ pub(super) fn add_screencast_dbus_members( object.add_method::(move |req, pr| { dbus_start(&state, req, pr); }); - object.set_property::(Variant::U32(MONITOR.0)); + object.set_property::(Variant::U32((MONITOR | WINDOW).0)); object.set_property::(Variant::U32(EMBEDDED.0)); object.set_property::(Variant::U32(5)); } diff --git a/src/tree/output.rs b/src/tree/output.rs index d21b3e5e..456e7a5d 100644 --- a/src/tree/output.rs +++ b/src/tree/output.rs @@ -460,9 +460,15 @@ impl OutputNode { } self.lock_surface.take(); self.jay_outputs.clear(); - self.screencasts.clear(); - self.screencopies.clear(); - self.ext_copy_sessions.clear(); + for screencast in self.screencasts.lock().drain_values() { + screencast.do_destroy(); + } + for screencopy in self.screencopies.lock().drain_values() { + screencopy.cancel(); + } + for session in self.ext_copy_sessions.lock().drain_values() { + session.stop(); + } self.ext_workspace_groups.clear(); self.latch_event.clear(); self.vblank_event.clear(); From 290b290fdf6ba834eebefbea8370b911bbb5bc5d Mon Sep 17 00:00:00 2001 From: atagen Date: Sun, 31 May 2026 17:23:56 +1000 Subject: [PATCH 04/10] Implement scratchpad window toggling --- jay-config/src/_private/client.rs | 12 ++ jay-config/src/_private/ipc.rs | 12 ++ jay-config/src/input.rs | 16 +++ jay-config/src/window.rs | 7 ++ src/compositor.rs | 1 + src/config/handler.rs | 35 ++++++ src/it/test_config.rs | 14 +++ src/it/tests.rs | 2 + src/it/tests/t0055_scratchpad.rs | 50 ++++++++ src/state.rs | 147 ++++++++++++++++++++++- src/tree/toplevel.rs | 61 ++++++++++ toml-config/src/config.rs | 8 ++ toml-config/src/config/parsers/action.rs | 22 ++++ toml-config/src/lib.rs | 4 + toml-spec/spec/spec.generated.json | 34 ++++++ toml-spec/spec/spec.generated.md | 53 ++++++++ toml-spec/spec/spec.yaml | 40 ++++++ 17 files changed, 515 insertions(+), 3 deletions(-) create mode 100644 src/it/tests/t0055_scratchpad.rs diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 7c78abac..57075e68 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -640,6 +640,18 @@ impl ConfigClient { self.send(&ClientMessage::SetWindowWorkspace { window, workspace }); } + pub fn seat_send_to_scratchpad(&self, seat: Seat, name: &str) { + self.send(&ClientMessage::SeatSendToScratchpad { seat, name }); + } + + pub fn seat_toggle_scratchpad(&self, seat: Seat, name: &str) { + self.send(&ClientMessage::SeatToggleScratchpad { seat, name }); + } + + pub fn window_send_to_scratchpad(&self, window: Window, name: &str) { + self.send(&ClientMessage::WindowSendToScratchpad { window, name }); + } + pub fn seat_split(&self, seat: Seat) -> Axis { let res = self.send_with_response(&ClientMessage::GetSeatSplit { seat }); get_response!(res, Axis::Horizontal, GetSplit { axis }); diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index c61c1af6..20ca2269 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -286,6 +286,14 @@ pub enum ClientMessage<'a> { seat: Seat, workspace: Workspace, }, + SeatSendToScratchpad { + seat: Seat, + name: &'a str, + }, + SeatToggleScratchpad { + seat: Seat, + name: &'a str, + }, GetTimer { name: &'a str, }, @@ -687,6 +695,10 @@ pub enum ClientMessage<'a> { window: Window, workspace: Workspace, }, + WindowSendToScratchpad { + window: Window, + name: &'a str, + }, SetWindowFullscreen { window: Window, fullscreen: bool, diff --git a/jay-config/src/input.rs b/jay-config/src/input.rs index dbdef1ba..560197c4 100644 --- a/jay-config/src/input.rs +++ b/jay-config/src/input.rs @@ -466,6 +466,22 @@ impl Seat { get!().set_seat_workspace(self, workspace) } + /// Sends the currently focused window to a scratchpad. + /// + /// Use an empty string for the default scratchpad. + pub fn send_to_scratchpad(self, name: &str) { + get!().seat_send_to_scratchpad(self, name) + } + + /// Toggles a scratchpad. + /// + /// If the scratchpad has a visible window, that window is hidden. Otherwise, the + /// most recently hidden window in the scratchpad is shown on the current workspace. + /// Use an empty string for the default scratchpad. + pub fn toggle_scratchpad(self, name: &str) { + get!().seat_toggle_scratchpad(self, name) + } + /// Toggles whether the currently focused window is fullscreen. pub fn toggle_fullscreen(self) { let c = get!(); diff --git a/jay-config/src/window.rs b/jay-config/src/window.rs index 662cda44..96e4d3b1 100644 --- a/jay-config/src/window.rs +++ b/jay-config/src/window.rs @@ -205,6 +205,13 @@ impl Window { get!().set_window_workspace(self, workspace) } + /// Sends the window to a scratchpad. + /// + /// Use an empty string for the default scratchpad. + pub fn send_to_scratchpad(self, name: &str) { + get!().window_send_to_scratchpad(self, name) + } + /// Toggles whether the currently focused window is fullscreen. pub fn toggle_fullscreen(self) { self.set_fullscreen(!self.fullscreen()) diff --git a/src/compositor.rs b/src/compositor.rs index 11f23808..4dd47342 100644 --- a/src/compositor.rs +++ b/src/compositor.rs @@ -403,6 +403,7 @@ fn start_compositor2( bo_drop_queue: Rc::new(ObjectDropQueue::new(&ring)), virtual_outputs: Default::default(), clean_logs_older_than: Default::default(), + scratchpads: 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 9a11acab..f6bc224f 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -1100,6 +1100,24 @@ impl ConfigProxyHandler { Ok(()) } + fn handle_seat_send_to_scratchpad(&self, seat: Seat, name: &str) -> Result<(), CphError> { + self.state.with_linear_layout_animations(|| { + let seat = self.get_seat(seat)?; + if let Some(toplevel) = seat.get_keyboard_node().node_toplevel() { + self.state.send_to_scratchpad(name, toplevel); + } + Ok(()) + }) + } + + fn handle_seat_toggle_scratchpad(&self, seat: Seat, name: &str) -> Result<(), CphError> { + self.state.with_linear_layout_animations(|| { + let seat = self.get_seat(seat)?; + self.state.toggle_scratchpad(&seat, name); + Ok(()) + }) + } + fn handle_set_window_workspace(&self, window: Window, ws: Workspace) -> Result<(), CphError> { let window = self.get_window(window)?; let name = self.get_workspace(ws)?; @@ -1114,6 +1132,14 @@ impl ConfigProxyHandler { Ok(()) } + fn handle_window_send_to_scratchpad(&self, window: Window, name: &str) -> Result<(), CphError> { + self.state.with_linear_layout_animations(|| { + let window = self.get_window(window)?; + self.state.send_to_scratchpad(name, window); + Ok(()) + }) + } + fn handle_get_device_name(&self, device: InputDevice) -> Result<(), CphError> { let dev = self.get_device_handler_data(device)?; let name = dev.device.name(); @@ -2989,6 +3015,12 @@ impl ConfigProxyHandler { ClientMessage::SetSeatWorkspace { seat, workspace } => self .handle_set_seat_workspace(seat, workspace) .wrn("set_seat_workspace")?, + ClientMessage::SeatSendToScratchpad { seat, name } => self + .handle_seat_send_to_scratchpad(seat, name) + .wrn("seat_send_to_scratchpad")?, + ClientMessage::SeatToggleScratchpad { seat, name } => self + .handle_seat_toggle_scratchpad(seat, name) + .wrn("seat_toggle_scratchpad")?, ClientMessage::GetConnector { ty, idx } => { self.handle_get_connector(ty, idx).wrn("get_connector")? } @@ -3373,6 +3405,9 @@ impl ConfigProxyHandler { ClientMessage::SetWindowWorkspace { window, workspace } => self .handle_set_window_workspace(window, workspace) .wrn("set_window_workspace")?, + ClientMessage::WindowSendToScratchpad { window, name } => self + .handle_window_send_to_scratchpad(window, name) + .wrn("window_send_to_scratchpad")?, ClientMessage::SetWindowFullscreen { window, fullscreen } => self .handle_set_window_fullscreen(window, fullscreen) .wrn("set_window_fullscreen")?, diff --git a/src/it/test_config.rs b/src/it/test_config.rs index 7691bbcd..5eba8aca 100644 --- a/src/it/test_config.rs +++ b/src/it/test_config.rs @@ -284,6 +284,20 @@ impl TestConfig { }) } + pub fn send_to_scratchpad(&self, seat: SeatId, name: &str) -> TestResult { + self.send(ClientMessage::SeatSendToScratchpad { + seat: Seat(seat.raw() as _), + name, + }) + } + + pub fn toggle_scratchpad(&self, seat: SeatId, name: &str) -> TestResult { + self.send(ClientMessage::SeatToggleScratchpad { + seat: Seat(seat.raw() as _), + name, + }) + } + fn clear(&self) { unsafe { if let Some(srv) = self.srv.take() { diff --git a/src/it/tests.rs b/src/it/tests.rs index 3e1e502c..35b6be97 100644 --- a/src/it/tests.rs +++ b/src/it/tests.rs @@ -86,6 +86,7 @@ mod t0052_bar; mod t0053_theme; mod t0054_subsurface_already_attached; mod t0055_autotiling; +mod t0055_scratchpad; pub trait TestCase: Sync { fn name(&self) -> &'static str; @@ -160,5 +161,6 @@ pub fn tests() -> Vec<&'static dyn TestCase> { t0053_theme, t0054_subsurface_already_attached, t0055_autotiling, + t0055_scratchpad, } } diff --git a/src/it/tests/t0055_scratchpad.rs b/src/it/tests/t0055_scratchpad.rs new file mode 100644 index 00000000..2519335a --- /dev/null +++ b/src/it/tests/t0055_scratchpad.rs @@ -0,0 +1,50 @@ +use { + crate::{ + it::{test_error::TestResult, testrun::TestRun}, + tree::Node, + }, + std::rc::Rc, +}; + +testcase!(); + +async fn test(run: Rc) -> TestResult { + let ds = run.create_default_setup().await?; + + let client = run.create_client().await?; + let win1 = client.create_window().await?; + win1.map2().await?; + let win2 = client.create_window().await?; + win2.map2().await?; + + run.cfg.send_to_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(win1.tl.server.node_visible()); + tassert!(!win2.tl.server.node_visible()); + + run.cfg.show_workspace(ds.seat.id(), "2")?; + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(win2.tl.server.node_visible()); + tassert_eq!(ds.output.workspace.get().unwrap().name.as_str(), "2"); + + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(!win2.tl.server.node_visible()); + + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(win2.tl.server.node_visible()); + tassert_eq!(ds.output.workspace.get().unwrap().name.as_str(), "2"); + + run.cfg.show_workspace(ds.seat.id(), "3")?; + client.sync().await; + tassert!(!win2.tl.server.node_visible()); + + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(win2.tl.server.node_visible()); + tassert_eq!(ds.output.workspace.get().unwrap().name.as_str(), "3"); + + Ok(()) +} diff --git a/src/state.rs b/src/state.rs index a7dad1d5..d10ff054 100644 --- a/src/state.rs +++ b/src/state.rs @@ -114,9 +114,11 @@ use { tree::{ ContainerNode, ContainerSplit, Direction, DisplayNode, FindTreeUsecase, FloatNode, FoundNode, LatchListener, Node, NodeId, NodeIds, NodeVisitorBase, OutputNode, - PlaceholderNode, TearingMode, TileState, ToplevelData, ToplevelIdentifier, - ToplevelNode, ToplevelNodeBase, Transform, VrrMode, WorkspaceDisplayOrder, - WorkspaceNode, WorkspaceNodeId, WsMoveConfig, generic_node_visitor, move_ws_to_output, + PlaceholderNode, ScratchpadToplevelState, TearingMode, TileState, ToplevelData, + ToplevelIdentifier, ToplevelNode, ToplevelNodeBase, Transform, VrrMode, + WorkspaceDisplayOrder, WorkspaceNode, WorkspaceNodeId, WsMoveConfig, + generic_node_visitor, move_ws_to_output, toplevel_hide_for_scratchpad, + toplevel_restore_from_scratchpad, toplevel_set_workspace, }, udmabuf::UdmabufHolder, utils::{ @@ -412,6 +414,7 @@ pub struct State { pub bo_drop_queue: Rc>>, pub virtual_outputs: VirtualOutputs, pub clean_logs_older_than: Cell>, + pub scratchpads: RefCell>>>, } // impl Drop for State { @@ -459,6 +462,28 @@ pub struct IdleState { pub in_grace_period: Cell, } +pub struct ScratchpadEntry { + node: Weak, + identifier: ToplevelIdentifier, + hidden: Cell, + restore: RefCell>, +} + +impl ScratchpadEntry { + fn alive(&self) -> bool { + self.node().is_some() + } + + fn node(&self) -> Option> { + let node = self.node.upgrade()?; + if node.tl_data().identifier.get() == self.identifier { + Some(node) + } else { + None + } + } +} + impl IdleState { pub fn set_timeout(&self, state: &State, timeout: Duration) { self.timeout.set(timeout); @@ -1023,6 +1048,121 @@ impl State { float } + pub fn send_to_scratchpad(self: &Rc, name: &str, node: Rc) { + if node.node_is_placeholder() { + return; + } + let identifier = node.tl_data().identifier.get(); + let entry = Rc::new(ScratchpadEntry { + node: Rc::downgrade(&node), + identifier, + hidden: Cell::new(false), + restore: Default::default(), + }); + let Some(restore) = toplevel_hide_for_scratchpad(node) else { + return; + }; + *entry.restore.borrow_mut() = Some(restore); + entry.hidden.set(true); + { + let mut scratchpads = self.scratchpads.borrow_mut(); + for entries in scratchpads.values_mut() { + entries.retain(|entry| entry.alive() && entry.identifier != identifier); + } + scratchpads + .entry(name.to_string()) + .or_default() + .push(entry.clone()); + } + self.tree_changed(); + } + + pub fn toggle_scratchpad(self: &Rc, seat: &Rc, name: &str) { + let entry = { + let mut scratchpads = self.scratchpads.borrow_mut(); + let Some(entries) = scratchpads.get_mut(name) else { + return; + }; + entries.retain(|entry| entry.alive()); + entries + .iter() + .rev() + .find(|entry| { + !entry.hidden.get() && entry.node().is_some_and(|node| node.node_visible()) + }) + .cloned() + .or_else(|| { + entries + .iter() + .rev() + .find(|entry| { + entry.hidden.get() + || entry.node().is_some_and(|node| !node.node_visible()) + }) + .cloned() + }) + }; + let Some(entry) = entry else { + return; + }; + if entry.hidden.get() { + self.show_scratchpad_entry(seat, &entry); + } else if entry.node().is_some_and(|node| !node.node_visible()) { + self.move_scratchpad_entry_to_current_workspace(seat, &entry); + } else { + self.hide_scratchpad_entry(&entry); + } + } + + fn hide_scratchpad_entry(self: &Rc, entry: &Rc) { + let Some(node) = entry.node() else { + return; + }; + if let Some(restore) = toplevel_hide_for_scratchpad(node) { + *entry.restore.borrow_mut() = Some(restore); + entry.hidden.set(true); + self.tree_changed(); + } + } + + fn show_scratchpad_entry( + self: &Rc, + seat: &Rc, + entry: &Rc, + ) { + if !entry.hidden.get() { + return; + } + let Some(node) = entry.node() else { + return; + }; + let restore = entry.restore.borrow(); + let Some(restore) = restore.as_ref() else { + return; + }; + let ws = seat.get_fallback_output().ensure_workspace(); + toplevel_restore_from_scratchpad(self, node.clone(), &ws, restore); + entry.hidden.set(false); + node.node_do_focus(seat, Direction::Unspecified); + seat.maybe_schedule_warp_mouse_to_focus(); + self.tree_changed(); + } + + fn move_scratchpad_entry_to_current_workspace( + self: &Rc, + seat: &Rc, + entry: &Rc, + ) { + let Some(node) = entry.node() else { + return; + }; + let ws = seat.get_fallback_output().ensure_workspace(); + toplevel_set_workspace(self, node.clone(), &ws); + node.node_do_focus(seat, Direction::Unspecified); + seat.maybe_schedule_warp_mouse_to_focus(); + self.tree_changed(); + } + fn focus_after_map(&self, node: Rc, seat: Option<&Rc>) { if !node.node_visible() { return; @@ -1298,6 +1438,7 @@ impl State { self.node_at_tree.borrow_mut().clear(); self.position_hint_requests.clear(); self.pending_warp_mouse_to_focus.clear(); + self.scratchpads.borrow_mut().clear(); self.head_managers.clear(); self.head_managers_async.clear(); self.const_40hz_latch.clear(); diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index 7fff564b..85661202 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -1323,3 +1323,64 @@ pub fn toplevel_set_workspace(state: &Rc, tl: Rc, ws: & tl.tl_set_fullscreen(true, Some(ws.clone())); } } + +pub struct ScratchpadToplevelState { + pub floating: bool, + pub fullscreen: bool, + pub workspace: Option>, +} + +pub fn toplevel_hide_for_scratchpad(tl: Rc) -> Option { + if tl.node_is_placeholder() { + return None; + } + let data = tl.tl_data(); + let scratchpad_state = ScratchpadToplevelState { + floating: data.parent_is_float.get(), + fullscreen: data.is_fullscreen.get(), + workspace: data.workspace.get(), + }; + if data.is_fullscreen.get() { + tl.clone().tl_set_fullscreen(false, None); + if data.is_fullscreen.get() { + return None; + } + } + let parent = data.parent.get()?; + let kb_foci = collect_kb_foci(tl.clone()); + parent.cnode_remove_child2(&*tl, true); + data.parent.take(); + data.float.take(); + if data.parent_is_float.replace(false) { + data.property_changed(TL_CHANGED_FLOATING); + } + if data.workspace.take().is_some() { + data.property_changed(TL_CHANGED_WORKSPACE); + } + tl.tl_set_visible(false); + if let Some(workspace) = &scratchpad_state.workspace { + for seat in kb_foci { + workspace + .clone() + .node_do_focus(&seat, Direction::Unspecified); + } + } + Some(scratchpad_state) +} + +pub fn toplevel_restore_from_scratchpad( + state: &Rc, + tl: Rc, + ws: &Rc, + scratchpad_state: &ScratchpadToplevelState, +) { + if scratchpad_state.floating { + let (width, height) = tl.tl_data().float_size(ws); + state.map_floating(tl.clone(), width, height, ws, None); + } else { + state.map_tiled_on(tl.clone(), ws); + } + if scratchpad_state.fullscreen && ws.fullscreen.is_none() { + tl.tl_set_fullscreen(true, Some(ws.clone())); + } +} diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 0eed4a21..894cb072 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -64,6 +64,8 @@ pub enum SimpleCommand { SetFloating(bool), ToggleFullscreen, SetFullscreen(bool), + SendToScratchpad, + ToggleScratchpad, Forward(bool), EnableWindowManagement(bool), SetFloatAboveFullscreen(bool), @@ -130,6 +132,12 @@ pub enum Action { MoveToWorkspace { name: String, }, + SendToScratchpad { + name: String, + }, + ToggleScratchpad { + name: String, + }, Multi { actions: Vec, }, diff --git a/toml-config/src/config/parsers/action.rs b/toml-config/src/config/parsers/action.rs index 7581198d..1afd8740 100644 --- a/toml-config/src/config/parsers/action.rs +++ b/toml-config/src/config/parsers/action.rs @@ -117,6 +117,8 @@ impl ActionParser<'_> { "toggle-fullscreen" => ToggleFullscreen, "enter-fullscreen" => SetFullscreen(true), "exit-fullscreen" => SetFullscreen(false), + "send-to-scratchpad" => SendToScratchpad, + "toggle-scratchpad" => ToggleScratchpad, "focus-parent" => FocusParent, "close" => Close, "disable-pointer-constraint" => DisablePointerConstraint, @@ -222,6 +224,24 @@ impl ActionParser<'_> { Ok(Action::MoveToWorkspace { name }) } + fn parse_send_to_scratchpad(&mut self, ext: &mut Extractor<'_>) -> ParseResult { + let name = ext + .extract(opt(str("name")))? + .map(|name| name.value) + .unwrap_or("") + .to_string(); + Ok(Action::SendToScratchpad { name }) + } + + fn parse_toggle_scratchpad(&mut self, ext: &mut Extractor<'_>) -> ParseResult { + let name = ext + .extract(opt(str("name")))? + .map(|name| name.value) + .unwrap_or("") + .to_string(); + Ok(Action::ToggleScratchpad { name }) + } + fn parse_configure_connector(&mut self, ext: &mut Extractor<'_>) -> ParseResult { let con = ext .extract(val("connector"))? @@ -551,6 +571,8 @@ impl Parser for ActionParser<'_> { "switch-to-vt" => self.parse_switch_to_vt(&mut ext), "show-workspace" => self.parse_show_workspace(&mut ext), "move-to-workspace" => self.parse_move_to_workspace(&mut ext), + "send-to-scratchpad" => self.parse_send_to_scratchpad(&mut ext), + "toggle-scratchpad" => self.parse_toggle_scratchpad(&mut ext), "configure-connector" => self.parse_configure_connector(&mut ext), "configure-input" => self.parse_configure_input(&mut ext), "configure-output" => self.parse_configure_output(&mut ext), diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index d8bfea89..cc09047b 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -173,6 +173,8 @@ impl Action { SimpleCommand::Move(dir) => window_or_seat!(s, s.move_(dir)), SimpleCommand::ToggleFullscreen => window_or_seat!(s, s.toggle_fullscreen()), SimpleCommand::SetFullscreen(b) => window_or_seat!(s, s.set_fullscreen(b)), + SimpleCommand::SendToScratchpad => window_or_seat!(s, s.send_to_scratchpad("")), + SimpleCommand::ToggleScratchpad => b.new(move || s.toggle_scratchpad("")), SimpleCommand::FocusParent => b.new(move || s.focus_parent()), SimpleCommand::Close => window_or_seat!(s, s.close()), SimpleCommand::DisablePointerConstraint => { @@ -306,6 +308,8 @@ impl Action { let workspace = get_workspace(&name); window_or_seat!(s, s.set_workspace(workspace)) } + Action::SendToScratchpad { name } => window_or_seat!(s, s.send_to_scratchpad(&name)), + Action::ToggleScratchpad { name } => b.new(move || s.toggle_scratchpad(&name)), Action::ConfigureConnector { con } => b.new(move || { for c in connectors() { if con.match_.matches(c) { diff --git a/toml-spec/spec/spec.generated.json b/toml-spec/spec/spec.generated.json index 4d6cb2bf..efed4522 100644 --- a/toml-spec/spec/spec.generated.json +++ b/toml-spec/spec/spec.generated.json @@ -162,6 +162,38 @@ "name" ] }, + { + "description": "Sends the currently focused window to a scratchpad and hides it.\n\nIf `name` is omitted, the default scratchpad is used.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-shift-minus = { type = \"send-to-scratchpad\", name = \"terminal\" }\n ```\n", + "type": "object", + "properties": { + "type": { + "const": "send-to-scratchpad" + }, + "name": { + "type": "string", + "description": "The name of the scratchpad." + } + }, + "required": [ + "type" + ] + }, + { + "description": "Toggles a scratchpad.\n\nIf the scratchpad has a visible window, that window is hidden. Otherwise, the\nmost recently hidden window in the scratchpad is shown on the current workspace.\nIf `name` is omitted, the default scratchpad is used.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-minus = { type = \"toggle-scratchpad\", name = \"terminal\" }\n ```\n", + "type": "object", + "properties": { + "type": { + "const": "toggle-scratchpad" + }, + "name": { + "type": "string", + "description": "The name of the scratchpad." + } + }, + "required": [ + "type" + ] + }, { "description": "Moves a workspace to a different output.\n\n- Example 1: Move a specific workspace to a named output\n\n ```toml\n [shortcuts]\n alt-F1 = { type = \"move-to-output\", workspace = \"1\", output.name = \"right\" }\n ```\n\n- Example 2: Move the current workspace to a named output\n\n ```toml\n [shortcuts]\n alt-F1 = { type = \"move-to-output\", output.name = \"right\" }\n ```\n\n- Example 3: Move the current workspace to the output on the right (directional)\n\n ```toml\n [shortcuts]\n \"logo+ctrl+shift+Right\" = { type = \"move-to-output\", direction = \"right\" }\n \"logo+ctrl+shift+Left\" = { type = \"move-to-output\", direction = \"left\" }\n \"logo+ctrl+shift+Up\" = { type = \"move-to-output\", direction = \"up\" }\n \"logo+ctrl+shift+Down\" = { type = \"move-to-output\", direction = \"down\" }\n ```\n", "type": "object", @@ -2078,6 +2110,8 @@ "toggle-fullscreen", "enter-fullscreen", "exit-fullscreen", + "send-to-scratchpad", + "toggle-scratchpad", "focus-parent", "close", "disable-pointer-constraint", diff --git a/toml-spec/spec/spec.generated.md b/toml-spec/spec/spec.generated.md index 1a9d82a8..df88e7c4 100644 --- a/toml-spec/spec/spec.generated.md +++ b/toml-spec/spec/spec.generated.md @@ -286,6 +286,50 @@ This table is a tagged union. The variant is determined by the `type` field. It The value of this field should be a string. +- `send-to-scratchpad`: + + Sends the currently focused window to a scratchpad and hides it. + + If `name` is omitted, the default scratchpad is used. + + - Example: + + ```toml + [shortcuts] + alt-shift-minus = { type = "send-to-scratchpad", name = "terminal" } + ``` + + The table has the following fields: + + - `name` (optional): + + The name of the scratchpad. + + The value of this field should be a string. + +- `toggle-scratchpad`: + + Toggles a scratchpad. + + If the scratchpad has a visible window, that window is hidden. Otherwise, the + most recently hidden window in the scratchpad is shown on the current workspace. + If `name` is omitted, the default scratchpad is used. + + - Example: + + ```toml + [shortcuts] + alt-minus = { type = "toggle-scratchpad", name = "terminal" } + ``` + + The table has the following fields: + + - `name` (optional): + + The name of the scratchpad. + + The value of this field should be a string. + - `move-to-output`: Moves a workspace to a different output. @@ -1007,6 +1051,7 @@ The string should have one of the following values: supported plan exists. + ### `Animations` @@ -4649,6 +4694,14 @@ The string should have one of the following values: Makes the currently focused window windowed. +- `send-to-scratchpad`: + + Sends the currently focused window to the default scratchpad. + +- `toggle-scratchpad`: + + Toggles the default scratchpad. + - `focus-parent`: Focus the parent of the currently focused window. diff --git a/toml-spec/spec/spec.yaml b/toml-spec/spec/spec.yaml index 7bc2b970..49731ad8 100644 --- a/toml-spec/spec/spec.yaml +++ b/toml-spec/spec/spec.yaml @@ -345,6 +345,42 @@ Action: description: The name of the workspace. required: true kind: string + send-to-scratchpad: + description: | + Sends the currently focused window to a scratchpad and hides it. + + If `name` is omitted, the default scratchpad is used. + + - Example: + + ```toml + [shortcuts] + alt-shift-minus = { type = "send-to-scratchpad", name = "terminal" } + ``` + fields: + name: + description: The name of the scratchpad. + required: false + kind: string + toggle-scratchpad: + description: | + Toggles a scratchpad. + + If the scratchpad has a visible window, that window is hidden. Otherwise, the + most recently hidden window in the scratchpad is shown on the current workspace. + If `name` is omitted, the default scratchpad is used. + + - Example: + + ```toml + [shortcuts] + alt-minus = { type = "toggle-scratchpad", name = "terminal" } + ``` + fields: + name: + description: The name of the scratchpad. + required: false + kind: string move-to-output: description: | Moves a workspace to a different output. @@ -1076,6 +1112,10 @@ SimpleActionName: description: Makes the currently focused window fullscreen. - value: exit-fullscreen description: Makes the currently focused window windowed. + - value: send-to-scratchpad + description: Sends the currently focused window to the default scratchpad. + - value: toggle-scratchpad + description: Toggles the default scratchpad. - value: focus-parent description: Focus the parent of the currently focused window. - value: close From bd715e8af514b4bc03c7944c3a64ff247c72b18d Mon Sep 17 00:00:00 2001 From: atagen Date: Sun, 31 May 2026 18:22:29 +1000 Subject: [PATCH 05/10] it: fix autotiling and suspend tests --- src/it/tests/t0022_toplevel_suspended.rs | 10 ++-- src/state.rs | 68 +++++++++--------------- src/tree/container.rs | 3 ++ 3 files changed, 34 insertions(+), 47 deletions(-) diff --git a/src/it/tests/t0022_toplevel_suspended.rs b/src/it/tests/t0022_toplevel_suspended.rs index 524856e3..5e871575 100644 --- a/src/it/tests/t0022_toplevel_suspended.rs +++ b/src/it/tests/t0022_toplevel_suspended.rs @@ -3,11 +3,9 @@ use { ifs::wl_surface::xdg_surface::xdg_toplevel::STATE_SUSPENDED, it::{ test_error::{TestErrorExt, TestResult}, - test_utils::{ - test_ouput_node_ext::TestOutputNodeExt, test_toplevel_node_ext::TestToplevelNodeExt, - }, testrun::TestRun, }, + tree::Node, }, isnt::std_1::collections::IsntHashSetExt, std::{rc::Rc, time::Duration}, @@ -29,7 +27,7 @@ async fn test(run: Rc) -> TestResult { win2.set_color(0, 255, 0, 255); win2.map2().await?; - let (x, y) = ds.output.first_toplevel()?.center(); + let (x, y) = win1.tl.server.node_absolute_position().center(); ds.move_to(x, y); tassert!(win2.tl.core.states.borrow().not_contains(&STATE_SUSPENDED)); @@ -45,6 +43,10 @@ async fn test(run: Rc) -> TestResult { client.sync().await; tassert!(win2.tl.core.states.borrow().not_contains(&STATE_SUSPENDED)); + let (x, y) = win2.tl.server.node_absolute_position().center(); + ds.move_to(x, y); + client.sync().await; + let leaves = default_seat.kb.leave.expect()?; let enters = default_seat.kb.enter.expect()?; diff --git a/src/state.rs b/src/state.rs index d10ff054..a80a73ce 100644 --- a/src/state.rs +++ b/src/state.rs @@ -4,12 +4,11 @@ use { allocator::BufferObject, animation::{ AnimationCurve, AnimationState, AnimationStyle, AnimationTick, RetainedExitLayer, - RetainedToplevel, - expand_damage_rect, + RetainedToplevel, expand_damage_rect, multiphase::{ MultiphasePhase, MultiphasePlan, MultiphasePlanFailure, MultiphaseRequest, - MultiphaseWindow, MultiphaseWindowHierarchy, - partition_motion_groups, plan_no_overlap_with_diagnostics, validate_phase_paths, + MultiphaseWindow, MultiphaseWindowHierarchy, partition_motion_groups, + plan_no_overlap_with_diagnostics, validate_phase_paths, }, spawn_in_start_rect, }, @@ -224,10 +223,7 @@ fn bridged_retarget_plan( return Err(MultiphasePlanFailure::NoPattern); }; let mut path = bridge_path.clone(); - let mut current = path - .last() - .map(|(_, to)| *to) - .unwrap_or(window.from); + let mut current = path.last().map(|(_, to)| *to).unwrap_or(window.from); while path.len() < bridge_phase_count { path.push((current, current)); } @@ -996,6 +992,12 @@ impl State { } else { lap.add_child_after(&*la, node); } + } else if let Some(last) = c.children.last() { + if autotile { + c.add_tiled_child_after(&*last.node, node); + } else { + c.add_child_after(&*last.node, node); + } } else { c.append_child(node); } @@ -1749,12 +1751,7 @@ impl State { self.eng.now().msec() } - pub fn queue_tiled_animation( - self: &Rc, - node_id: NodeId, - old: Rect, - new: Rect, - ) { + pub fn queue_tiled_animation(self: &Rc, node_id: NodeId, old: Rect, new: Rect) { let curve = self .layout_animation_curve_override .get() @@ -1782,12 +1779,7 @@ impl State { self.queue_layout_animation(node_id, old, new, curve, hierarchy); } - pub fn queue_linear_layout_animation( - self: &Rc, - node_id: NodeId, - old: Rect, - new: Rect, - ) { + pub fn queue_linear_layout_animation(self: &Rc, node_id: NodeId, old: Rect, new: Rect) { self.queue_layout_animation( node_id, old, @@ -2140,11 +2132,7 @@ impl State { started_any } - pub fn queue_spawn_in_animation( - self: &Rc, - node_id: NodeId, - target: Rect, - ) { + pub fn queue_spawn_in_animation(self: &Rc, node_id: NodeId, target: Rect) { if !self.animations.enabled.get() || target.is_empty() { return; } @@ -2805,10 +2793,7 @@ impl State { #[cfg(test)] mod tests { - use { - super::*, - crate::animation::multiphase::MultiphaseHierarchyPosition, - }; + use {super::*, crate::animation::multiphase::MultiphaseHierarchyPosition}; fn rect(x1: i32, y1: i32, x2: i32, y2: i32) -> Rect { Rect::new_saturating(x1, y1, x2, y2) @@ -2822,12 +2807,7 @@ mod tests { } fn candidate(node_id: u32, style: AnimationStyle) -> LayoutAnimationCandidate { - candidate_rects( - node_id, - rect(0, 0, 100, 100), - rect(100, 0, 200, 100), - style, - ) + candidate_rects(node_id, rect(0, 0, 100, 100), rect(100, 0, 200, 100), style) } fn candidate_rects( @@ -2898,14 +2878,16 @@ mod tests { ) .unwrap(); - assert!(plan - .phases - .iter() - .any(|phase| phase.steps.iter().any(|step| step.node_id == NodeId(1)))); - assert!(plan - .phases - .iter() - .any(|phase| phase.steps.iter().any(|step| step.node_id == NodeId(3)))); + assert!( + plan.phases + .iter() + .any(|phase| phase.steps.iter().any(|step| step.node_id == NodeId(1))) + ); + assert!( + plan.phases + .iter() + .any(|phase| phase.steps.iter().any(|step| step.node_id == NodeId(3))) + ); } #[test] diff --git a/src/tree/container.rs b/src/tree/container.rs index 44a6a778..e9363106 100644 --- a/src/tree/container.rs +++ b/src/tree/container.rs @@ -2633,6 +2633,9 @@ impl ToplevelNodeBase for ContainerNode { if let Some(last) = self.focus_history.last() { return last.node.clone().tl_last_active_child(); } + if let Some(last) = self.children.last() { + return last.node.clone().tl_last_active_child(); + } self } From e3c323c296f4b25d9b37e51a9d8e64a57a35eb99 Mon Sep 17 00:00:00 2001 From: atagen Date: Wed, 3 Jun 2026 16:51:26 +1000 Subject: [PATCH 06/10] feat: implement declarative scratchpads --- jay-config/src/_private/client.rs | 4 + jay-config/src/_private/ipc.rs | 4 + jay-config/src/input.rs | 11 ++ src/config/handler.rs | 11 ++ src/it/test_config.rs | 7 ++ src/it/tests/t0055_scratchpad.rs | 59 ++++++++- src/state.rs | 84 ++++++++----- src/tree/toplevel.rs | 44 +++---- toml-config/src/config.rs | 4 + toml-config/src/config/parsers.rs | 1 + toml-config/src/config/parsers/action.rs | 11 ++ toml-config/src/config/parsers/config.rs | 8 ++ toml-config/src/config/parsers/scratchpad.rs | 87 +++++++++++++ toml-config/src/lib.rs | 41 ++++++- toml-spec/spec/spec.generated.json | 46 ++++++- toml-spec/spec/spec.generated.md | 122 ++++++++++++++++--- toml-spec/spec/spec.yaml | 83 ++++++++++++- 17 files changed, 549 insertions(+), 78 deletions(-) create mode 100644 toml-config/src/config/parsers/scratchpad.rs diff --git a/jay-config/src/_private/client.rs b/jay-config/src/_private/client.rs index 57075e68..151e7591 100644 --- a/jay-config/src/_private/client.rs +++ b/jay-config/src/_private/client.rs @@ -648,6 +648,10 @@ impl ConfigClient { self.send(&ClientMessage::SeatToggleScratchpad { seat, name }); } + pub fn seat_cycle_scratchpad(&self, seat: Seat, name: &str) { + self.send(&ClientMessage::SeatCycleScratchpad { seat, name }); + } + pub fn window_send_to_scratchpad(&self, window: Window, name: &str) { self.send(&ClientMessage::WindowSendToScratchpad { window, name }); } diff --git a/jay-config/src/_private/ipc.rs b/jay-config/src/_private/ipc.rs index 20ca2269..743acc57 100644 --- a/jay-config/src/_private/ipc.rs +++ b/jay-config/src/_private/ipc.rs @@ -294,6 +294,10 @@ pub enum ClientMessage<'a> { seat: Seat, name: &'a str, }, + SeatCycleScratchpad { + seat: Seat, + name: &'a str, + }, GetTimer { name: &'a str, }, diff --git a/jay-config/src/input.rs b/jay-config/src/input.rs index 560197c4..450597e2 100644 --- a/jay-config/src/input.rs +++ b/jay-config/src/input.rs @@ -477,11 +477,22 @@ impl Seat { /// /// If the scratchpad has a visible window, that window is hidden. Otherwise, the /// most recently hidden window in the scratchpad is shown on the current workspace. + /// Scratchpad windows are always shown floating. /// Use an empty string for the default scratchpad. pub fn toggle_scratchpad(self, name: &str) { get!().seat_toggle_scratchpad(self, name) } + /// Cycles through the windows of a scratchpad, one at a time. + /// + /// With nothing shown, the first window is brought up; each further invocation + /// hides the current window and shows the next; after the last window the + /// scratchpad is hidden again. Scratchpad windows are always shown floating. + /// Use an empty string for the default scratchpad. + pub fn cycle_scratchpad(self, name: &str) { + get!().seat_cycle_scratchpad(self, name) + } + /// Toggles whether the currently focused window is fullscreen. pub fn toggle_fullscreen(self) { let c = get!(); diff --git a/src/config/handler.rs b/src/config/handler.rs index f6bc224f..68ea93f5 100644 --- a/src/config/handler.rs +++ b/src/config/handler.rs @@ -1118,6 +1118,14 @@ impl ConfigProxyHandler { }) } + fn handle_seat_cycle_scratchpad(&self, seat: Seat, name: &str) -> Result<(), CphError> { + self.state.with_linear_layout_animations(|| { + let seat = self.get_seat(seat)?; + self.state.cycle_scratchpad(&seat, name); + Ok(()) + }) + } + fn handle_set_window_workspace(&self, window: Window, ws: Workspace) -> Result<(), CphError> { let window = self.get_window(window)?; let name = self.get_workspace(ws)?; @@ -3021,6 +3029,9 @@ impl ConfigProxyHandler { ClientMessage::SeatToggleScratchpad { seat, name } => self .handle_seat_toggle_scratchpad(seat, name) .wrn("seat_toggle_scratchpad")?, + ClientMessage::SeatCycleScratchpad { seat, name } => self + .handle_seat_cycle_scratchpad(seat, name) + .wrn("seat_cycle_scratchpad")?, ClientMessage::GetConnector { ty, idx } => { self.handle_get_connector(ty, idx).wrn("get_connector")? } diff --git a/src/it/test_config.rs b/src/it/test_config.rs index 5eba8aca..8cb39935 100644 --- a/src/it/test_config.rs +++ b/src/it/test_config.rs @@ -298,6 +298,13 @@ impl TestConfig { }) } + pub fn cycle_scratchpad(&self, seat: SeatId, name: &str) -> TestResult { + self.send(ClientMessage::SeatCycleScratchpad { + seat: Seat(seat.raw() as _), + name, + }) + } + fn clear(&self) { unsafe { if let Some(srv) = self.srv.take() { diff --git a/src/it/tests/t0055_scratchpad.rs b/src/it/tests/t0055_scratchpad.rs index 2519335a..5abf2440 100644 --- a/src/it/tests/t0055_scratchpad.rs +++ b/src/it/tests/t0055_scratchpad.rs @@ -1,7 +1,7 @@ use { crate::{ it::{test_error::TestResult, testrun::TestRun}, - tree::Node, + tree::{Node, ToplevelNodeBase}, }, std::rc::Rc, }; @@ -45,6 +45,63 @@ async fn test(run: Rc) -> TestResult { client.sync().await; tassert!(win2.tl.server.node_visible()); tassert_eq!(ds.output.workspace.get().unwrap().name.as_str(), "3"); + // Scratchpad windows are always shown floating. + tassert!(win2.tl.server.tl_data().parent_is_float.get()); + + // Park win2 again, then build a multi-window scratchpad and cycle it. + run.cfg.toggle_scratchpad(ds.seat.id(), "term")?; + client.sync().await; + tassert!(!win2.tl.server.node_visible()); + + // Build a three-window scratchpad. Each window is focused right after it is + // mapped, so sending the focused window parks them in a known order. + let cyc1 = client.create_window().await?; + cyc1.map2().await?; + run.cfg.send_to_scratchpad(ds.seat.id(), "cyc")?; + let cyc2 = client.create_window().await?; + cyc2.map2().await?; + run.cfg.send_to_scratchpad(ds.seat.id(), "cyc")?; + let cyc3 = client.create_window().await?; + cyc3.map2().await?; + run.cfg.send_to_scratchpad(ds.seat.id(), "cyc")?; + client.sync().await; + tassert!(!cyc1.tl.server.node_visible()); + tassert!(!cyc2.tl.server.node_visible()); + tassert!(!cyc3.tl.server.node_visible()); + + // Nothing shown: cycle brings up the first window (insertion order: cyc1). + run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?; + client.sync().await; + tassert!(cyc1.tl.server.node_visible()); + tassert!(!cyc2.tl.server.node_visible()); + tassert!(!cyc3.tl.server.node_visible()); + // Scratchpad windows are always shown floating. + tassert!(cyc1.tl.server.tl_data().parent_is_float.get()); + + // Cycle advances one at a time. + run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?; + client.sync().await; + tassert!(!cyc1.tl.server.node_visible()); + tassert!(cyc2.tl.server.node_visible()); + tassert!(!cyc3.tl.server.node_visible()); + + run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?; + client.sync().await; + tassert!(!cyc1.tl.server.node_visible()); + tassert!(!cyc2.tl.server.node_visible()); + tassert!(cyc3.tl.server.node_visible()); + + // On the final window, the next cycle hides everything. + run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?; + client.sync().await; + tassert!(!cyc1.tl.server.node_visible()); + tassert!(!cyc2.tl.server.node_visible()); + tassert!(!cyc3.tl.server.node_visible()); + + // And it wraps back to the first window. + run.cfg.cycle_scratchpad(ds.seat.id(), "cyc")?; + client.sync().await; + tassert!(cyc1.tl.server.node_visible()); Ok(()) } diff --git a/src/state.rs b/src/state.rs index a80a73ce..1af6c209 100644 --- a/src/state.rs +++ b/src/state.rs @@ -113,7 +113,7 @@ use { tree::{ ContainerNode, ContainerSplit, Direction, DisplayNode, FindTreeUsecase, FloatNode, FoundNode, LatchListener, Node, NodeId, NodeIds, NodeVisitorBase, OutputNode, - PlaceholderNode, ScratchpadToplevelState, TearingMode, TileState, ToplevelData, + PlaceholderNode, TearingMode, TileState, ToplevelData, ToplevelIdentifier, ToplevelNode, ToplevelNodeBase, Transform, VrrMode, WorkspaceDisplayOrder, WorkspaceNode, WorkspaceNodeId, WsMoveConfig, generic_node_visitor, move_ws_to_output, toplevel_hide_for_scratchpad, @@ -462,7 +462,6 @@ pub struct ScratchpadEntry { node: Weak, identifier: ToplevelIdentifier, hidden: Cell, - restore: RefCell>, } impl ScratchpadEntry { @@ -1055,17 +1054,14 @@ impl State { return; } let identifier = node.tl_data().identifier.get(); + if !toplevel_hide_for_scratchpad(node.clone()) { + return; + } let entry = Rc::new(ScratchpadEntry { node: Rc::downgrade(&node), identifier, - hidden: Cell::new(false), - restore: Default::default(), + hidden: Cell::new(true), }); - let Some(restore) = toplevel_hide_for_scratchpad(node) else { - return; - }; - *entry.restore.borrow_mut() = Some(restore); - entry.hidden.set(true); { let mut scratchpads = self.scratchpads.borrow_mut(); for entries in scratchpads.values_mut() { @@ -1074,7 +1070,7 @@ impl State { scratchpads .entry(name.to_string()) .or_default() - .push(entry.clone()); + .push(entry); } self.tree_changed(); } @@ -1086,29 +1082,19 @@ impl State { return; }; entries.retain(|entry| entry.alive()); + // Prefer the currently-shown window; otherwise act on the most recent. entries .iter() .rev() - .find(|entry| { - !entry.hidden.get() && entry.node().is_some_and(|node| node.node_visible()) - }) + .find(|entry| !entry.hidden.get()) + .or_else(|| entries.last()) .cloned() - .or_else(|| { - entries - .iter() - .rev() - .find(|entry| { - entry.hidden.get() - || entry.node().is_some_and(|node| !node.node_visible()) - }) - .cloned() - }) }; let Some(entry) = entry else { return; }; if entry.hidden.get() { - self.show_scratchpad_entry(seat, &entry); + self.show_scratchpad_entry(seat, name, &entry); } else if entry.node().is_some_and(|node| !node.node_visible()) { self.move_scratchpad_entry_to_current_workspace(seat, &entry); } else { @@ -1116,12 +1102,39 @@ impl State { } } + /// Cycles through the windows of a scratchpad, one at a time: + /// nothing shown -> first window -> ... -> last window -> nothing shown. + pub fn cycle_scratchpad(self: &Rc, seat: &Rc, name: &str) { + let (current, next) = { + let mut scratchpads = self.scratchpads.borrow_mut(); + let Some(entries) = scratchpads.get_mut(name) else { + return; + }; + entries.retain(|entry| entry.alive()); + match entries.iter().position(|entry| !entry.hidden.get()) { + // Nothing shown yet: bring up the first window. + None => (None, entries.first().cloned()), + // Hide the shown window and advance; on the last window, `next` + // is `None`, so the scratchpad toggles off. + Some(i) => (entries.get(i).cloned(), entries.get(i + 1).cloned()), + } + }; + if let Some(current) = ¤t { + self.hide_scratchpad_entry(current); + } + if let Some(next) = &next { + self.show_scratchpad_entry(seat, name, next); + } + } + fn hide_scratchpad_entry(self: &Rc, entry: &Rc) { + if entry.hidden.get() { + return; + } let Some(node) = entry.node() else { return; }; - if let Some(restore) = toplevel_hide_for_scratchpad(node) { - *entry.restore.borrow_mut() = Some(restore); + if toplevel_hide_for_scratchpad(node) { entry.hidden.set(true); self.tree_changed(); } @@ -1130,6 +1143,7 @@ impl State { fn show_scratchpad_entry( self: &Rc, seat: &Rc, + name: &str, entry: &Rc, ) { if !entry.hidden.get() { @@ -1138,12 +1152,22 @@ impl State { let Some(node) = entry.node() else { return; }; - let restore = entry.restore.borrow(); - let Some(restore) = restore.as_ref() else { - return; + // Only one window of a scratchpad is visible at a time. + let siblings: Vec<_> = { + let scratchpads = self.scratchpads.borrow(); + scratchpads + .get(name) + .into_iter() + .flatten() + .filter(|sibling| !Rc::ptr_eq(sibling, entry) && !sibling.hidden.get()) + .cloned() + .collect() }; + for sibling in siblings { + self.hide_scratchpad_entry(&sibling); + } let ws = seat.get_fallback_output().ensure_workspace(); - toplevel_restore_from_scratchpad(self, node.clone(), &ws, restore); + toplevel_restore_from_scratchpad(self, node.clone(), &ws); entry.hidden.set(false); node.node_do_focus(seat, Direction::Unspecified); seat.maybe_schedule_warp_mouse_to_focus(); diff --git a/src/tree/toplevel.rs b/src/tree/toplevel.rs index 85661202..c0a2f013 100644 --- a/src/tree/toplevel.rs +++ b/src/tree/toplevel.rs @@ -1324,29 +1324,25 @@ pub fn toplevel_set_workspace(state: &Rc, tl: Rc, ws: & } } -pub struct ScratchpadToplevelState { - pub floating: bool, - pub fullscreen: bool, - pub workspace: Option>, -} - -pub fn toplevel_hide_for_scratchpad(tl: Rc) -> Option { +/// Removes a toplevel from the tree so it can be parked in a scratchpad. +/// +/// Returns `true` if the window was hidden. A placeholder, a window without a +/// parent, or a window that refuses to leave fullscreen cannot be parked. +pub fn toplevel_hide_for_scratchpad(tl: Rc) -> bool { if tl.node_is_placeholder() { - return None; + return false; } let data = tl.tl_data(); - let scratchpad_state = ScratchpadToplevelState { - floating: data.parent_is_float.get(), - fullscreen: data.is_fullscreen.get(), - workspace: data.workspace.get(), - }; + let workspace = data.workspace.get(); if data.is_fullscreen.get() { tl.clone().tl_set_fullscreen(false, None); if data.is_fullscreen.get() { - return None; + return false; } } - let parent = data.parent.get()?; + let Some(parent) = data.parent.get() else { + return false; + }; let kb_foci = collect_kb_foci(tl.clone()); parent.cnode_remove_child2(&*tl, true); data.parent.take(); @@ -1358,29 +1354,23 @@ pub fn toplevel_hide_for_scratchpad(tl: Rc) -> Option, tl: Rc, ws: &Rc, - scratchpad_state: &ScratchpadToplevelState, ) { - if scratchpad_state.floating { - let (width, height) = tl.tl_data().float_size(ws); - state.map_floating(tl.clone(), width, height, ws, None); - } else { - state.map_tiled_on(tl.clone(), ws); - } - if scratchpad_state.fullscreen && ws.fullscreen.is_none() { - tl.tl_set_fullscreen(true, Some(ws.clone())); - } + let (width, height) = tl.tl_data().float_size(ws); + state.map_floating(tl.clone(), width, height, ws, None); } diff --git a/toml-config/src/config.rs b/toml-config/src/config.rs index 894cb072..b57de5ad 100644 --- a/toml-config/src/config.rs +++ b/toml-config/src/config.rs @@ -66,6 +66,7 @@ pub enum SimpleCommand { SetFullscreen(bool), SendToScratchpad, ToggleScratchpad, + CycleScratchpad, Forward(bool), EnableWindowManagement(bool), SetFloatAboveFullscreen(bool), @@ -138,6 +139,9 @@ pub enum Action { ToggleScratchpad { name: String, }, + CycleScratchpad { + name: String, + }, Multi { actions: Vec, }, diff --git a/toml-config/src/config/parsers.rs b/toml-config/src/config/parsers.rs index e353a2f8..98d3ab73 100644 --- a/toml-config/src/config/parsers.rs +++ b/toml-config/src/config/parsers.rs @@ -41,6 +41,7 @@ pub mod modified_keysym; mod output; mod output_match; mod repeat_rate; +mod scratchpad; pub mod shortcuts; mod simple_im; mod status; diff --git a/toml-config/src/config/parsers/action.rs b/toml-config/src/config/parsers/action.rs index 1afd8740..29fdc3e4 100644 --- a/toml-config/src/config/parsers/action.rs +++ b/toml-config/src/config/parsers/action.rs @@ -119,6 +119,7 @@ impl ActionParser<'_> { "exit-fullscreen" => SetFullscreen(false), "send-to-scratchpad" => SendToScratchpad, "toggle-scratchpad" => ToggleScratchpad, + "cycle-scratchpad" => CycleScratchpad, "focus-parent" => FocusParent, "close" => Close, "disable-pointer-constraint" => DisablePointerConstraint, @@ -242,6 +243,15 @@ impl ActionParser<'_> { Ok(Action::ToggleScratchpad { name }) } + fn parse_cycle_scratchpad(&mut self, ext: &mut Extractor<'_>) -> ParseResult { + let name = ext + .extract(opt(str("name")))? + .map(|name| name.value) + .unwrap_or("") + .to_string(); + Ok(Action::CycleScratchpad { name }) + } + fn parse_configure_connector(&mut self, ext: &mut Extractor<'_>) -> ParseResult { let con = ext .extract(val("connector"))? @@ -573,6 +583,7 @@ impl Parser for ActionParser<'_> { "move-to-workspace" => self.parse_move_to_workspace(&mut ext), "send-to-scratchpad" => self.parse_send_to_scratchpad(&mut ext), "toggle-scratchpad" => self.parse_toggle_scratchpad(&mut ext), + "cycle-scratchpad" => self.parse_cycle_scratchpad(&mut ext), "configure-connector" => self.parse_configure_connector(&mut ext), "configure-input" => self.parse_configure_input(&mut ext), "configure-output" => self.parse_configure_output(&mut ext), diff --git a/toml-config/src/config/parsers/config.rs b/toml-config/src/config/parsers/config.rs index 112f7471..8e776860 100644 --- a/toml-config/src/config/parsers/config.rs +++ b/toml-config/src/config/parsers/config.rs @@ -28,6 +28,7 @@ use { log_level::LogLevelParser, output::OutputsParser, repeat_rate::RepeatRateParser, + scratchpad::ScratchpadsParser, shortcuts::{ ComplexShortcutsParser, ShortcutsParser, ShortcutsParserError, parse_modified_keysym_str, @@ -570,6 +571,13 @@ impl Parser for ConfigParser<'_> { } } } + let mut scratchpads = vec![]; + if let Some(value) = scratchpads_val { + match value.parse(&mut ScratchpadsParser(self.0)) { + Ok(v) => scratchpads = v, + Err(e) => log::warn!("Could not parse the scratchpads: {}", self.0.error(e)), + } + } Ok(Config { keymap, repeat_rate, diff --git a/toml-config/src/config/parsers/scratchpad.rs b/toml-config/src/config/parsers/scratchpad.rs new file mode 100644 index 00000000..17cc5238 --- /dev/null +++ b/toml-config/src/config/parsers/scratchpad.rs @@ -0,0 +1,87 @@ +use { + crate::{ + config::{ + Scratchpad, + context::Context, + extractor::{Extractor, ExtractorError, opt, str, val}, + parser::{DataType, ParseResult, Parser, UnexpectedDataType}, + parsers::exec::{ExecParser, ExecParserError}, + }, + toml::{ + toml_span::{Span, Spanned}, + toml_value::Value, + }, + }, + indexmap::IndexMap, + thiserror::Error, +}; + +#[derive(Debug, Error)] +pub enum ScratchpadParserError { + #[error(transparent)] + Expected(#[from] UnexpectedDataType), + #[error(transparent)] + Extract(#[from] ExtractorError), + #[error(transparent)] + Exec(#[from] ExecParserError), +} + +pub struct ScratchpadParser<'a>(pub &'a Context<'a>); + +impl Parser for ScratchpadParser<'_> { + type Value = Scratchpad; + type Error = ScratchpadParserError; + const EXPECTED: &'static [DataType] = &[DataType::Table]; + + fn parse_table( + &mut self, + span: Span, + table: &IndexMap, Spanned>, + ) -> ParseResult { + let mut ext = Extractor::new(self.0, span, table); + let (name, exec_val) = ext.extract((str("name"), opt(val("exec"))))?; + let exec = match exec_val { + None => None, + Some(e) => Some(e.parse_map(&mut ExecParser(self.0))?), + }; + Ok(Scratchpad { + name: name.value.to_string(), + exec, + }) + } +} + +pub struct ScratchpadsParser<'a>(pub &'a Context<'a>); + +impl Parser for ScratchpadsParser<'_> { + type Value = Vec; + type Error = ScratchpadParserError; + const EXPECTED: &'static [DataType] = &[DataType::Table, DataType::Array]; + + fn parse_array(&mut self, _span: Span, array: &[Spanned]) -> ParseResult { + let mut res = vec![]; + for el in array { + match el.parse(&mut ScratchpadParser(self.0)) { + Ok(o) => res.push(o), + Err(e) => { + log::warn!("Could not parse scratchpad: {}", self.0.error(e)); + } + } + } + Ok(res) + } + + fn parse_table( + &mut self, + span: Span, + table: &IndexMap, Spanned>, + ) -> ParseResult { + log::warn!( + "`scratchpads` value should be an array: {}", + self.0.error3(span) + ); + ScratchpadParser(self.0) + .parse_table(span, table) + .map(|v| vec![v]) + } +} diff --git a/toml-config/src/lib.rs b/toml-config/src/lib.rs index cc09047b..6e3430f8 100644 --- a/toml-config/src/lib.rs +++ b/toml-config/src/lib.rs @@ -15,7 +15,7 @@ use { config::{ Action, AnimationCurveConfig, ClientRule, Config, ConfigConnector, ConfigDrmDevice, ConfigKeymap, ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output, - OutputMatch, SimpleCommand, Status, Theme, WindowRule, parse_config, + OutputMatch, SimpleCommand, Status, Theme, WindowMatch, WindowRule, parse_config, }, rules::{MatcherTemp, RuleMapper}, shortcuts::ModeState, @@ -175,6 +175,7 @@ impl Action { SimpleCommand::SetFullscreen(b) => window_or_seat!(s, s.set_fullscreen(b)), SimpleCommand::SendToScratchpad => window_or_seat!(s, s.send_to_scratchpad("")), SimpleCommand::ToggleScratchpad => b.new(move || s.toggle_scratchpad("")), + SimpleCommand::CycleScratchpad => b.new(move || s.cycle_scratchpad("")), SimpleCommand::FocusParent => b.new(move || s.focus_parent()), SimpleCommand::Close => window_or_seat!(s, s.close()), SimpleCommand::DisablePointerConstraint => { @@ -310,6 +311,7 @@ impl Action { } Action::SendToScratchpad { name } => window_or_seat!(s, s.send_to_scratchpad(&name)), Action::ToggleScratchpad { name } => b.new(move || s.toggle_scratchpad(&name)), + Action::CycleScratchpad { name } => b.new(move || s.cycle_scratchpad(&name)), Action::ConfigureConnector { con } => b.new(move || { for c in connectors() { if con.match_.matches(c) { @@ -1461,6 +1463,43 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc