1
0
Fork 0
forked from wry/wry

Compare commits

...
Sign in to create a new pull request.

6 commits

58 changed files with 1294 additions and 162 deletions

View file

@ -640,6 +640,22 @@ impl ConfigClient {
self.send(&ClientMessage::SetWindowWorkspace { window, workspace }); 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 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 });
}
pub fn seat_split(&self, seat: Seat) -> Axis { pub fn seat_split(&self, seat: Seat) -> Axis {
let res = self.send_with_response(&ClientMessage::GetSeatSplit { seat }); let res = self.send_with_response(&ClientMessage::GetSeatSplit { seat });
get_response!(res, Axis::Horizontal, GetSplit { axis }); get_response!(res, Axis::Horizontal, GetSplit { axis });

View file

@ -286,6 +286,18 @@ pub enum ClientMessage<'a> {
seat: Seat, seat: Seat,
workspace: Workspace, workspace: Workspace,
}, },
SeatSendToScratchpad {
seat: Seat,
name: &'a str,
},
SeatToggleScratchpad {
seat: Seat,
name: &'a str,
},
SeatCycleScratchpad {
seat: Seat,
name: &'a str,
},
GetTimer { GetTimer {
name: &'a str, name: &'a str,
}, },
@ -687,6 +699,10 @@ pub enum ClientMessage<'a> {
window: Window, window: Window,
workspace: Workspace, workspace: Workspace,
}, },
WindowSendToScratchpad {
window: Window,
name: &'a str,
},
SetWindowFullscreen { SetWindowFullscreen {
window: Window, window: Window,
fullscreen: bool, fullscreen: bool,

View file

@ -466,6 +466,33 @@ impl Seat {
get!().set_seat_workspace(self, workspace) 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.
/// 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. /// Toggles whether the currently focused window is fullscreen.
pub fn toggle_fullscreen(self) { pub fn toggle_fullscreen(self) {
let c = get!(); let c = get!();

View file

@ -205,6 +205,13 @@ impl Window {
get!().set_window_workspace(self, workspace) 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. /// Toggles whether the currently focused window is fullscreen.
pub fn toggle_fullscreen(self) { pub fn toggle_fullscreen(self) {
self.set_fullscreen(!self.fullscreen()) self.set_fullscreen(!self.fullscreen())

View file

@ -403,6 +403,7 @@ fn start_compositor2(
bo_drop_queue: Rc::new(ObjectDropQueue::new(&ring)), bo_drop_queue: Rc::new(ObjectDropQueue::new(&ring)),
virtual_outputs: Default::default(), virtual_outputs: Default::default(),
clean_logs_older_than: Default::default(), clean_logs_older_than: Default::default(),
scratchpads: Default::default(),
}); });
state.tracker.register(ClientId::from_raw(0)); state.tracker.register(ClientId::from_raw(0));
create_dummy_output(&state); create_dummy_output(&state);

View file

@ -1100,6 +1100,32 @@ impl ConfigProxyHandler {
Ok(()) 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_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> { fn handle_set_window_workspace(&self, window: Window, ws: Workspace) -> Result<(), CphError> {
let window = self.get_window(window)?; let window = self.get_window(window)?;
let name = self.get_workspace(ws)?; let name = self.get_workspace(ws)?;
@ -1114,6 +1140,14 @@ impl ConfigProxyHandler {
Ok(()) 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> { fn handle_get_device_name(&self, device: InputDevice) -> Result<(), CphError> {
let dev = self.get_device_handler_data(device)?; let dev = self.get_device_handler_data(device)?;
let name = dev.device.name(); let name = dev.device.name();
@ -2989,6 +3023,15 @@ impl ConfigProxyHandler {
ClientMessage::SetSeatWorkspace { seat, workspace } => self ClientMessage::SetSeatWorkspace { seat, workspace } => self
.handle_set_seat_workspace(seat, workspace) .handle_set_seat_workspace(seat, workspace)
.wrn("set_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::SeatCycleScratchpad { seat, name } => self
.handle_seat_cycle_scratchpad(seat, name)
.wrn("seat_cycle_scratchpad")?,
ClientMessage::GetConnector { ty, idx } => { ClientMessage::GetConnector { ty, idx } => {
self.handle_get_connector(ty, idx).wrn("get_connector")? self.handle_get_connector(ty, idx).wrn("get_connector")?
} }
@ -3373,6 +3416,9 @@ impl ConfigProxyHandler {
ClientMessage::SetWindowWorkspace { window, workspace } => self ClientMessage::SetWindowWorkspace { window, workspace } => self
.handle_set_window_workspace(window, workspace) .handle_set_window_workspace(window, workspace)
.wrn("set_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 ClientMessage::SetWindowFullscreen { window, fullscreen } => self
.handle_set_window_fullscreen(window, fullscreen) .handle_set_window_fullscreen(window, fullscreen)
.wrn("set_window_fullscreen")?, .wrn("set_window_fullscreen")?,

View file

@ -86,9 +86,7 @@ impl ExtImageCopyCaptureFrameV1 {
let buffer = self.session.buffer.get().unwrap(); let buffer = self.session.buffer.get().unwrap();
if size != buffer.rect.size() { if size != buffer.rect.size() {
self.session.buffer_size_changed(); self.session.buffer_size_changed();
// https://gitlab.freedesktop.org/wayland/wayland-protocols/-/issues/222 return Err(FrameFailureReason::BufferConstraints);
// self.fail(FrameFailureReason::BufferConstraints);
// return;
} }
if let Err(e) = buffer.update_framebuffer() { if let Err(e) = buffer.update_framebuffer() {
log::error!("Could not import buffer: {}", ErrorFmt(e)); log::error!("Could not import buffer: {}", ErrorFmt(e));
@ -102,6 +100,13 @@ impl ExtImageCopyCaptureFrameV1 {
let mut shm_staging = self.session.shm_staging.take(); let mut shm_staging = self.session.shm_staging.take();
match storage { match storage {
WlBufferStorage::Shm { mem, stride, .. } => { 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 if let Some(b) = &shm_bridge
&& (b.physical_size() != buffer.rect.size() && (b.physical_size() != buffer.rect.size()
|| b.format() != buffer.format || b.format() != buffer.format
@ -159,6 +164,12 @@ impl ExtImageCopyCaptureFrameV1 {
self.session.shm_staging.set(Some(staging)); self.session.shm_staging.set(Some(staging));
} }
WlBufferStorage::Dmabuf { fb, .. } => { 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 { let Some(fb) = fb else {
return Err(FrameFailureReason::BufferConstraints); return Err(FrameFailureReason::BufferConstraints);
}; };
@ -187,7 +198,11 @@ impl ExtImageCopyCaptureFrameV1 {
) { ) {
match self.try_copy(on, size, f) { match self.try_copy(on, size, f) {
Ok(()) => self.session.status.set(FrameStatus::Captured), Ok(()) => self.session.status.set(FrameStatus::Captured),
Err(e) => self.fail(e), Err(e) => {
if self.session.status.get() != FrameStatus::Failed {
self.fail(e);
}
}
} }
} }

View file

@ -83,6 +83,11 @@ impl ExtImageCopyCaptureSessionV1 {
if self.size_debounce.replace(true) { if self.size_debounce.replace(true) {
return; 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.force_capture.set(true);
self.send_current_buffer_size(); self.send_current_buffer_size();
self.send_done(); self.send_done();

View file

@ -1520,25 +1520,25 @@ impl WlSurface {
let bounds = self.toplevel.get().and_then(|tl| tl.tl_render_bounds()); let bounds = self.toplevel.get().and_then(|tl| tl.tl_render_bounds());
let pos = self.buffer_abs_pos.get(); let pos = self.buffer_abs_pos.get();
let apply_damage = |pos: Rect| { let apply_damage = |pos: Rect| {
if pending.damage_full { let clip_damage = |mut damage: Rect| {
let mut damage = pos; damage = damage.intersect(pos);
if let Some(bounds) = bounds { if let Some(bounds) = bounds {
damage = damage.intersect(bounds); damage = damage.intersect(bounds);
} }
self.client.state.damage(damage); damage
};
if pending.damage_full {
self.client.state.damage(clip_damage(pos));
} else { } else {
let matrix = self.damage_matrix.get(); let matrix = self.damage_matrix.get();
if let Some(buffer) = self.buffer.get() { if let Some(buffer) = self.buffer.get() {
for damage in &pending.buffer_damage { for damage in &pending.buffer_damage {
let mut damage = matrix.apply( let damage = matrix.apply(
pos.x1(), pos.x1(),
pos.y1(), pos.y1(),
damage.intersect(buffer.buffer.buf.rect), damage.intersect(buffer.buffer.buf.rect),
); );
if let Some(bounds) = bounds { self.client.state.damage(clip_damage(damage));
damage = damage.intersect(bounds);
}
self.client.state.damage(damage);
} }
} }
for damage in &pending.surface_damage { for damage in &pending.surface_damage {
@ -1550,8 +1550,7 @@ impl WlSurface {
let y2 = (damage.y2() + scale - 1) / scale; let y2 = (damage.y2() + scale - 1) / scale;
damage = Rect::new_saturating(x1, y1, x2, y2); damage = Rect::new_saturating(x1, y1, x2, y2);
} }
damage = damage.intersect(bounds.unwrap_or(pos)); self.client.state.damage(clip_damage(damage));
self.client.state.damage(damage);
} }
} }
}; };

View file

@ -48,17 +48,14 @@ impl ZwlrScreencopyFrameV1 {
} }
pub fn send_damage(&self) { pub fn send_damage(&self) {
if let Some(output) = self.output.get() {
let pos = output.pos.get();
self.client.event(Damage { self.client.event(Damage {
self_id: self.id, self_id: self.id,
x: 0, x: 0,
y: 0, y: 0,
width: pos.width() as _, width: self.rect.width() as _,
height: pos.height() as _, height: self.rect.height() as _,
}); });
} }
}
pub fn send_buffer(&self) { pub fn send_buffer(&self) {
self.client.event(Buffer { self.client.event(Buffer {
@ -111,11 +108,29 @@ impl ZwlrScreencopyFrameV1 {
return Err(ZwlrScreencopyFrameV1Error::InvalidBufferFormat); return Err(ZwlrScreencopyFrameV1Error::InvalidBufferFormat);
} }
buffer.update_framebuffer()?; buffer.update_framebuffer()?;
if let Some(WlBufferStorage::Shm { stride, .. }) = buffer.storage.borrow_mut().deref() match buffer.storage.borrow_mut().deref() {
&& *stride != self.rect.width() * 4 Some(WlBufferStorage::Shm { stride, .. }) => {
{ if *stride != self.rect.width() * 4 {
return Err(ZwlrScreencopyFrameV1Error::InvalidBufferStride); 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)); self.buffer.set(Some(buffer));
if !with_damage && let Some(global) = self.output.get() { if !with_damage && let Some(global) = self.output.get() {
global.connector.damage(); global.connector.damage();
@ -134,6 +149,12 @@ impl ZwlrScreencopyFrameV1 {
} }
self.pending.take(); self.pending.take();
} }
pub fn cancel(&self) {
self.buffer.take();
self.pending.take();
self.send_failed();
}
} }
impl ZwlrScreencopyFrameV1RequestHandler for ZwlrScreencopyFrameV1 { impl ZwlrScreencopyFrameV1RequestHandler for ZwlrScreencopyFrameV1 {

View file

@ -104,8 +104,8 @@ impl ZwlrScreencopyManagerV1 {
let Some(global) = output.global.get() else { let Some(global) = output.global.get() else {
return Ok(()); return Ok(());
}; };
let mode = global.mode.get(); let (width, height) = global.pixel_size();
let mut rect = Rect::new_sized_saturating(0, 0, mode.width, mode.height); let mut rect = Rect::new_sized_saturating(0, 0, width, height);
if let Some(region) = region { if let Some(region) = region {
let scale = global.persistent.scale.get().to_f64(); let scale = global.persistent.scale.get().to_f64();
let x1 = (region.x1() as f64 * scale).round() as i32; let x1 = (region.x1() as f64 * scale).round() as i32;

View file

@ -284,6 +284,27 @@ 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,
})
}
pub fn cycle_scratchpad(&self, seat: SeatId, name: &str) -> TestResult {
self.send(ClientMessage::SeatCycleScratchpad {
seat: Seat(seat.raw() as _),
name,
})
}
fn clear(&self) { fn clear(&self) {
unsafe { unsafe {
if let Some(srv) = self.srv.take() { if let Some(srv) = self.srv.take() {

View file

@ -29,6 +29,17 @@ impl TestViewport {
Ok(()) 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> { pub fn set_destination(&self, width: i32, height: i32) -> Result<(), TestError> {
self.tran.send(SetDestination { self.tran.send(SetDestination {
self_id: self.id, self_id: self.id,
@ -37,6 +48,15 @@ impl TestViewport {
})?; })?;
Ok(()) 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 { impl Drop for TestViewport {

View file

@ -86,6 +86,7 @@ mod t0052_bar;
mod t0053_theme; mod t0053_theme;
mod t0054_subsurface_already_attached; mod t0054_subsurface_already_attached;
mod t0055_autotiling; mod t0055_autotiling;
mod t0055_scratchpad;
pub trait TestCase: Sync { pub trait TestCase: Sync {
fn name(&self) -> &'static str; fn name(&self) -> &'static str;
@ -160,5 +161,6 @@ pub fn tests() -> Vec<&'static dyn TestCase> {
t0053_theme, t0053_theme,
t0054_subsurface_already_attached, t0054_subsurface_already_attached,
t0055_autotiling, t0055_autotiling,
t0055_scratchpad,
} }
} }

View file

@ -1,7 +1,6 @@
use { use {
crate::{ crate::{
it::{test_error::TestError, testrun::TestRun}, it::{test_error::TestError, testrun::TestRun},
rect::Rect,
tree::Node, tree::Node,
}, },
std::rc::Rc, std::rc::Rc,
@ -11,29 +10,19 @@ testcase!();
/// Create and map a single surface /// Create and map a single surface
async fn test(run: Rc<TestRun>) -> Result<(), TestError> { async fn test(run: Rc<TestRun>) -> Result<(), TestError> {
run.backend.install_default()?; let ds = run.create_default_setup().await?;
let client = run.create_client().await?; let client = run.create_client().await?;
let window = client.create_window().await?; let window = client.create_window().await?;
window.map().await?; window.map().await?;
tassert_eq!(window.tl.core.width.get(), 800); let workspace_rect = ds.output.workspace_rect.get();
tassert_eq!(
window.tl.core.height.get(),
600 - 2 * run.state.theme.title_plus_underline_height()
);
tassert_eq!( tassert_eq!(window.tl.core.width.get(), workspace_rect.width());
window.tl.server.node_absolute_position(), tassert_eq!(window.tl.core.height.get(), workspace_rect.height());
Rect::new_sized(
0, tassert_eq!(window.tl.server.node_absolute_position(), workspace_rect);
2 * run.state.theme.title_plus_underline_height(),
window.tl.core.width.get(),
window.tl.core.height.get(),
)
.unwrap()
);
Ok(()) Ok(())
} }

View file

@ -11,7 +11,7 @@ testcase!();
/// Create and map two surfaces /// Create and map two surfaces
async fn test(run: Rc<TestRun>) -> Result<(), TestError> { async fn test(run: Rc<TestRun>) -> Result<(), TestError> {
run.backend.install_default()?; let ds = run.create_default_setup().await?;
let client = run.create_client().await?; let client = run.create_client().await?;
@ -21,17 +21,30 @@ async fn test(run: Rc<TestRun>) -> Result<(), TestError> {
let window2 = client.create_window().await?; let window2 = client.create_window().await?;
window2.map().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 bw = run.state.theme.sizes.border_width.get();
let child_width = (workspace_rect.width() - bw) / 2;
tassert_eq!( tassert_eq!(
window.tl.server.node_absolute_position(), 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!( tassert_eq!(
window2.tl.server.node_absolute_position(), 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(()) Ok(())

View file

@ -48,13 +48,18 @@ async fn test(run: Rc<TestRun>) -> TestResult {
let mono_container = w_mono2.tl.container_parent()?; let mono_container = w_mono2.tl.container_parent()?;
let container_pos = mono_container.tl_data().pos.get(); let container_pos = mono_container.tl_data().pos.get();
let w_mono1_title = mono_container.render_data.borrow_mut().title_rects[0] let (tab_x, tab_y) = {
.move_(container_pos.x1(), container_pos.y1()); let tab_bar = mono_container.tab_bar.borrow();
ds.mouse.abs( let Some(tab_bar) = tab_bar.as_ref() else {
&ds.connector, bail!("no tab bar");
w_mono1_title.x1() as _, };
w_mono1_title.y1() as _, 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; client.sync().await;
tassert!(enters.next().is_err()); tassert!(enters.next().is_err());

View file

@ -26,12 +26,18 @@ async fn test(run: Rc<TestRun>) -> TestResult {
let container = w_mono2.tl.container_parent()?; let container = w_mono2.tl.container_parent()?;
let pos = container.tl_data().pos.get(); let pos = container.tl_data().pos.get();
let w_mono1_title = container.render_data.borrow_mut().title_rects[0].move_(pos.x1(), pos.y1()); let (tab_x, tab_y) = {
ds.mouse.abs( let tab_bar = container.tab_bar.borrow();
&ds.connector, let Some(tab_bar) = tab_bar.as_ref() else {
w_mono1_title.x1() as f64, bail!("no tab bar");
w_mono1_title.y1() as f64, };
); 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; client.sync().await;
let enters = dss.kb.enter.expect()?; let enters = dss.kb.enter.expect()?;

View file

@ -2,15 +2,13 @@ use {
crate::{ crate::{
ifs::wl_surface::xdg_surface::xdg_toplevel::STATE_SUSPENDED, ifs::wl_surface::xdg_surface::xdg_toplevel::STATE_SUSPENDED,
it::{ it::{
test_error::TestResult, test_error::{TestErrorExt, TestResult},
test_utils::{
test_ouput_node_ext::TestOutputNodeExt, test_toplevel_node_ext::TestToplevelNodeExt,
},
testrun::TestRun, testrun::TestRun,
}, },
tree::Node,
}, },
isnt::std_1::collections::IsntHashSetExt, isnt::std_1::collections::IsntHashSetExt,
std::rc::Rc, std::{rc::Rc, time::Duration},
}; };
testcase!(); testcase!();
@ -19,6 +17,7 @@ async fn test(run: Rc<TestRun>) -> TestResult {
let ds = run.create_default_setup().await?; let ds = run.create_default_setup().await?;
let client = run.create_client().await?; let client = run.create_client().await?;
let default_seat = client.get_default_seat().await?;
let win1 = client.create_window().await?; let win1 = client.create_window().await?;
win1.set_color(255, 0, 0, 255); win1.set_color(255, 0, 0, 255);
@ -28,7 +27,7 @@ async fn test(run: Rc<TestRun>) -> TestResult {
win2.set_color(0, 255, 0, 255); win2.set_color(0, 255, 0, 255);
win2.map2().await?; 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); ds.move_to(x, y);
tassert!(win2.tl.core.states.borrow().not_contains(&STATE_SUSPENDED)); tassert!(win2.tl.core.states.borrow().not_contains(&STATE_SUSPENDED));
@ -44,5 +43,27 @@ async fn test(run: Rc<TestRun>) -> TestResult {
client.sync().await; client.sync().await;
tassert!(win2.tl.core.states.borrow().not_contains(&STATE_SUSPENDED)); 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()?;
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(()) Ok(())
} }

View file

@ -308,9 +308,8 @@ async fn test(run: Rc<TestRun>) -> TestResult {
let output_damage = connector_data.damage.borrow(); let output_damage = connector_data.damage.borrow();
tassert!(!output_damage.is_empty()); tassert!(!output_damage.is_empty());
// Buffer damage is transformed by the damage matrix which includes the surface position // The test window maps its 1x1 buffer through a viewport to the full window size.
// The buffer damage (0,0,1,1) should be transformed to surface coordinates let expected_buffer_damage = surface_pos;
let expected_buffer_damage = buffer_damage.move_(surface_pos.x1(), surface_pos.y1());
// Find the exact output damage that matches our expected buffer damage // Find the exact output damage that matches our expected buffer damage
let mut found_exact_buffer_damage = false; let mut found_exact_buffer_damage = false;
@ -331,10 +330,12 @@ async fn test(run: Rc<TestRun>) -> TestResult {
// Test 7: Check output damage from existing window's viewport (which already has scaling) // Test 7: Check output damage from existing window's viewport (which already has scaling)
connector_data.damage.borrow_mut().clear(); connector_data.damage.borrow_mut().clear();
// The existing window was created with create_surface_ext() which automatically creates a viewport // 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 // Commit the viewport size change separately; that commit intentionally damages the old/new extents.
// First, let's modify the viewport scaling that already exists on the window window.surface.viewport.set_destination(150, 100)?;
window.surface.viewport.set_destination(150, 100)?; // Change scaling to 150x100 window.surface.commit()?;
client.sync().await;
connector_data.damage.borrow_mut().clear();
// Add buffer damage to test viewport scaling coordinate transformation // Add buffer damage to test viewport scaling coordinate transformation
window.surface.damage_buffer(0, 0, 1, 1)?; // Damage entire 1x1 buffer window.surface.damage_buffer(0, 0, 1, 1)?; // Damage entire 1x1 buffer
@ -346,8 +347,8 @@ async fn test(run: Rc<TestRun>) -> TestResult {
let output_damage = connector_data.damage.borrow(); let output_damage = connector_data.damage.borrow();
tassert!(!output_damage.is_empty()); tassert!(!output_damage.is_empty());
// With viewporter scaling, the 1x1 buffer damage should scale to 150x100 // With viewporter scaling, the 1x1 buffer damage should scale to the viewport destination.
// and be moved by surface position (0, 36) to get output coordinates (0, 36, 150, 136) let surface_pos = window.surface.server.buffer_abs_pos.get();
let expected_scaled_damage = Rect::new_sized(0, 0, 150, 100).unwrap(); let expected_scaled_damage = Rect::new_sized(0, 0, 150, 100).unwrap();
let expected_output_damage = let expected_output_damage =
expected_scaled_damage.move_(surface_pos.x1(), surface_pos.y1()); expected_scaled_damage.move_(surface_pos.x1(), surface_pos.y1());
@ -402,8 +403,9 @@ async fn test(run: Rc<TestRun>) -> TestResult {
rotation_window.map().await?; rotation_window.map().await?;
client.sync().await; client.sync().await;
// Disable viewporter by setting destination to 0x0 to rely purely on buffer dimensions // Disable viewporter to rely purely on buffer dimensions.
rotation_window.surface.viewport.set_destination(0, 0)?; // Disable viewporter rotation_window.surface.viewport.unset_source()?;
rotation_window.surface.viewport.unset_destination()?;
// Use a rectangular buffer (4x2) so rotation has a visible geometric effect // 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 // Attach AFTER mapping to avoid being overwritten by map()'s single-pixel buffer

View file

@ -0,0 +1,107 @@
use {
crate::{
it::{test_error::TestResult, testrun::TestRun},
tree::{Node, ToplevelNodeBase},
},
std::rc::Rc,
};
testcase!();
async fn test(run: Rc<TestRun>) -> 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");
// 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(())
}

View file

@ -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 self.node
.send_port_output_buffers(&self.port, &self.buffers.borrow()); .send_port_output_buffers(&self.port, &self.buffers.borrow());
} }
@ -633,15 +641,18 @@ impl UsrJayScreencastOwner for StartedScreencast {
fn ready(&self, ev: &Ready) { fn ready(&self, ev: &Ready) {
let idx = ev.idx as usize; let idx = ev.idx as usize;
let buffers = &*self.buffers.borrow();
let pbuffers = self.port.buffers.borrow();
let buffer = &buffers[idx];
let discard_buffer = || { let discard_buffer = || {
self.jay_screencast.release_buffer(idx); self.jay_screencast.release_buffer(idx);
}; };
if !self.buffers_valid.get() { if !self.buffers_valid.get() {
return; 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 { let Some(io) = self.port.io_buffers.get() else {
discard_buffer(); discard_buffer();
return; return;
@ -767,7 +778,7 @@ pub(super) fn add_screencast_dbus_members(
object.add_method::<Start, _>(move |req, pr| { object.add_method::<Start, _>(move |req, pr| {
dbus_start(&state, req, pr); dbus_start(&state, req, pr);
}); });
object.set_property::<AvailableSourceTypes>(Variant::U32(MONITOR.0)); object.set_property::<AvailableSourceTypes>(Variant::U32((MONITOR | WINDOW).0));
object.set_property::<AvailableCursorModes>(Variant::U32(EMBEDDED.0)); object.set_property::<AvailableCursorModes>(Variant::U32(EMBEDDED.0));
object.set_property::<version>(Variant::U32(5)); object.set_property::<version>(Variant::U32(5));
} }

View file

@ -4,12 +4,11 @@ use {
allocator::BufferObject, allocator::BufferObject,
animation::{ animation::{
AnimationCurve, AnimationState, AnimationStyle, AnimationTick, RetainedExitLayer, AnimationCurve, AnimationState, AnimationStyle, AnimationTick, RetainedExitLayer,
RetainedToplevel, RetainedToplevel, expand_damage_rect,
expand_damage_rect,
multiphase::{ multiphase::{
MultiphasePhase, MultiphasePlan, MultiphasePlanFailure, MultiphaseRequest, MultiphasePhase, MultiphasePlan, MultiphasePlanFailure, MultiphaseRequest,
MultiphaseWindow, MultiphaseWindowHierarchy, MultiphaseWindow, MultiphaseWindowHierarchy, partition_motion_groups,
partition_motion_groups, plan_no_overlap_with_diagnostics, validate_phase_paths, plan_no_overlap_with_diagnostics, validate_phase_paths,
}, },
spawn_in_start_rect, spawn_in_start_rect,
}, },
@ -114,9 +113,11 @@ use {
tree::{ tree::{
ContainerNode, ContainerSplit, Direction, DisplayNode, FindTreeUsecase, FloatNode, ContainerNode, ContainerSplit, Direction, DisplayNode, FindTreeUsecase, FloatNode,
FoundNode, LatchListener, Node, NodeId, NodeIds, NodeVisitorBase, OutputNode, FoundNode, LatchListener, Node, NodeId, NodeIds, NodeVisitorBase, OutputNode,
PlaceholderNode, TearingMode, TileState, ToplevelData, ToplevelIdentifier, PlaceholderNode, TearingMode, TileState, ToplevelData,
ToplevelNode, ToplevelNodeBase, Transform, VrrMode, WorkspaceDisplayOrder, ToplevelIdentifier, ToplevelNode, ToplevelNodeBase, Transform, VrrMode,
WorkspaceNode, WorkspaceNodeId, WsMoveConfig, generic_node_visitor, move_ws_to_output, WorkspaceDisplayOrder, WorkspaceNode, WorkspaceNodeId, WsMoveConfig,
generic_node_visitor, move_ws_to_output, toplevel_hide_for_scratchpad,
toplevel_restore_from_scratchpad, toplevel_set_workspace,
}, },
udmabuf::UdmabufHolder, udmabuf::UdmabufHolder,
utils::{ utils::{
@ -222,10 +223,7 @@ fn bridged_retarget_plan(
return Err(MultiphasePlanFailure::NoPattern); return Err(MultiphasePlanFailure::NoPattern);
}; };
let mut path = bridge_path.clone(); let mut path = bridge_path.clone();
let mut current = path let mut current = path.last().map(|(_, to)| *to).unwrap_or(window.from);
.last()
.map(|(_, to)| *to)
.unwrap_or(window.from);
while path.len() < bridge_phase_count { while path.len() < bridge_phase_count {
path.push((current, current)); path.push((current, current));
} }
@ -412,6 +410,7 @@ pub struct State {
pub bo_drop_queue: Rc<ObjectDropQueue<Rc<dyn BufferObject>>>, pub bo_drop_queue: Rc<ObjectDropQueue<Rc<dyn BufferObject>>>,
pub virtual_outputs: VirtualOutputs, pub virtual_outputs: VirtualOutputs,
pub clean_logs_older_than: Cell<Option<SystemTime>>, pub clean_logs_older_than: Cell<Option<SystemTime>>,
pub scratchpads: RefCell<AHashMap<String, Vec<Rc<ScratchpadEntry>>>>,
} }
// impl Drop for State { // impl Drop for State {
@ -459,6 +458,27 @@ pub struct IdleState {
pub in_grace_period: Cell<bool>, pub in_grace_period: Cell<bool>,
} }
pub struct ScratchpadEntry {
node: Weak<dyn ToplevelNode>,
identifier: ToplevelIdentifier,
hidden: Cell<bool>,
}
impl ScratchpadEntry {
fn alive(&self) -> bool {
self.node().is_some()
}
fn node(&self) -> Option<Rc<dyn ToplevelNode>> {
let node = self.node.upgrade()?;
if node.tl_data().identifier.get() == self.identifier {
Some(node)
} else {
None
}
}
}
impl IdleState { impl IdleState {
pub fn set_timeout(&self, state: &State, timeout: Duration) { pub fn set_timeout(&self, state: &State, timeout: Duration) {
self.timeout.set(timeout); self.timeout.set(timeout);
@ -971,6 +991,12 @@ impl State {
} else { } else {
lap.add_child_after(&*la, node); 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 { } else {
c.append_child(node); c.append_child(node);
} }
@ -1023,6 +1049,146 @@ impl State {
float float
} }
pub fn send_to_scratchpad(self: &Rc<Self>, name: &str, node: Rc<dyn ToplevelNode>) {
if node.node_is_placeholder() {
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(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);
}
self.tree_changed();
}
pub fn toggle_scratchpad(self: &Rc<Self>, seat: &Rc<WlSeatGlobal>, 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());
// Prefer the currently-shown window; otherwise act on the most recent.
entries
.iter()
.rev()
.find(|entry| !entry.hidden.get())
.or_else(|| entries.last())
.cloned()
};
let Some(entry) = entry else {
return;
};
if entry.hidden.get() {
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 {
self.hide_scratchpad_entry(&entry);
}
}
/// 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<Self>, seat: &Rc<WlSeatGlobal>, 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) = &current {
self.hide_scratchpad_entry(current);
}
if let Some(next) = &next {
self.show_scratchpad_entry(seat, name, next);
}
}
fn hide_scratchpad_entry(self: &Rc<Self>, entry: &Rc<ScratchpadEntry>) {
if entry.hidden.get() {
return;
}
let Some(node) = entry.node() else {
return;
};
if toplevel_hide_for_scratchpad(node) {
entry.hidden.set(true);
self.tree_changed();
}
}
fn show_scratchpad_entry(
self: &Rc<Self>,
seat: &Rc<WlSeatGlobal>,
name: &str,
entry: &Rc<ScratchpadEntry>,
) {
if !entry.hidden.get() {
return;
}
let Some(node) = entry.node() 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);
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<Self>,
seat: &Rc<WlSeatGlobal>,
entry: &Rc<ScratchpadEntry>,
) {
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<dyn ToplevelNode>, seat: Option<&Rc<WlSeatGlobal>>) { fn focus_after_map(&self, node: Rc<dyn ToplevelNode>, seat: Option<&Rc<WlSeatGlobal>>) {
if !node.node_visible() { if !node.node_visible() {
return; return;
@ -1298,6 +1464,7 @@ impl State {
self.node_at_tree.borrow_mut().clear(); self.node_at_tree.borrow_mut().clear();
self.position_hint_requests.clear(); self.position_hint_requests.clear();
self.pending_warp_mouse_to_focus.clear(); self.pending_warp_mouse_to_focus.clear();
self.scratchpads.borrow_mut().clear();
self.head_managers.clear(); self.head_managers.clear();
self.head_managers_async.clear(); self.head_managers_async.clear();
self.const_40hz_latch.clear(); self.const_40hz_latch.clear();
@ -1608,12 +1775,7 @@ impl State {
self.eng.now().msec() self.eng.now().msec()
} }
pub fn queue_tiled_animation( pub fn queue_tiled_animation(self: &Rc<Self>, node_id: NodeId, old: Rect, new: Rect) {
self: &Rc<Self>,
node_id: NodeId,
old: Rect,
new: Rect,
) {
let curve = self let curve = self
.layout_animation_curve_override .layout_animation_curve_override
.get() .get()
@ -1641,12 +1803,7 @@ impl State {
self.queue_layout_animation(node_id, old, new, curve, hierarchy); self.queue_layout_animation(node_id, old, new, curve, hierarchy);
} }
pub fn queue_linear_layout_animation( pub fn queue_linear_layout_animation(self: &Rc<Self>, node_id: NodeId, old: Rect, new: Rect) {
self: &Rc<Self>,
node_id: NodeId,
old: Rect,
new: Rect,
) {
self.queue_layout_animation( self.queue_layout_animation(
node_id, node_id,
old, old,
@ -1999,11 +2156,7 @@ impl State {
started_any started_any
} }
pub fn queue_spawn_in_animation( pub fn queue_spawn_in_animation(self: &Rc<Self>, node_id: NodeId, target: Rect) {
self: &Rc<Self>,
node_id: NodeId,
target: Rect,
) {
if !self.animations.enabled.get() || target.is_empty() { if !self.animations.enabled.get() || target.is_empty() {
return; return;
} }
@ -2664,10 +2817,7 @@ impl State {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use { use {super::*, crate::animation::multiphase::MultiphaseHierarchyPosition};
super::*,
crate::animation::multiphase::MultiphaseHierarchyPosition,
};
fn rect(x1: i32, y1: i32, x2: i32, y2: i32) -> Rect { fn rect(x1: i32, y1: i32, x2: i32, y2: i32) -> Rect {
Rect::new_saturating(x1, y1, x2, y2) Rect::new_saturating(x1, y1, x2, y2)
@ -2681,12 +2831,7 @@ mod tests {
} }
fn candidate(node_id: u32, style: AnimationStyle) -> LayoutAnimationCandidate { fn candidate(node_id: u32, style: AnimationStyle) -> LayoutAnimationCandidate {
candidate_rects( candidate_rects(node_id, rect(0, 0, 100, 100), rect(100, 0, 200, 100), style)
node_id,
rect(0, 0, 100, 100),
rect(100, 0, 200, 100),
style,
)
} }
fn candidate_rects( fn candidate_rects(
@ -2757,14 +2902,16 @@ mod tests {
) )
.unwrap(); .unwrap();
assert!(plan assert!(
.phases plan.phases
.iter() .iter()
.any(|phase| phase.steps.iter().any(|step| step.node_id == NodeId(1)))); .any(|phase| phase.steps.iter().any(|step| step.node_id == NodeId(1)))
assert!(plan );
.phases assert!(
plan.phases
.iter() .iter()
.any(|phase| phase.steps.iter().any(|step| step.node_id == NodeId(3)))); .any(|phase| phase.steps.iter().any(|step| step.node_id == NodeId(3)))
);
} }
#[test] #[test]

View file

@ -32,6 +32,7 @@ use {
numcell::NumCell, numcell::NumCell,
on_drop_event::OnDropEvent, on_drop_event::OnDropEvent,
rc_eq::rc_eq, rc_eq::rc_eq,
scroller::Scroller,
threshold_counter::ThresholdCounter, threshold_counter::ThresholdCounter,
}, },
}, },
@ -150,6 +151,7 @@ pub struct ContainerNode {
pub child_removed: Rc<LazyEventSource>, pub child_removed: Rc<LazyEventSource>,
pub all_children_resized: Rc<LazyEventSource>, pub all_children_resized: Rc<LazyEventSource>,
pub tab_bar: RefCell<Option<TabBar>>, pub tab_bar: RefCell<Option<TabBar>>,
scroll: Scroller,
pub update_tab_textures_scheduled: Cell<bool>, pub update_tab_textures_scheduled: Cell<bool>,
pub ephemeral: Cell<Ephemeral>, pub ephemeral: Cell<Ephemeral>,
} }
@ -266,6 +268,7 @@ impl ContainerNode {
child_removed: state.lazy_event_sources.create_source(), child_removed: state.lazy_event_sources.create_source(),
all_children_resized: state.post_layout_event_sources.create_source(), all_children_resized: state.post_layout_event_sources.create_source(),
tab_bar: RefCell::new(None), tab_bar: RefCell::new(None),
scroll: Default::default(),
update_tab_textures_scheduled: Cell::new(false), update_tab_textures_scheduled: Cell::new(false),
ephemeral: Cell::new(Ephemeral::Off), ephemeral: Cell::new(Ephemeral::Off),
}); });
@ -793,6 +796,18 @@ impl ContainerNode {
self.activate_child2(child, false); self.activate_child2(child, false);
} }
fn activate_child_from_input(
self: &Rc<Self>,
child: &NodeRef<ContainerChild>,
seat: &Rc<WlSeatGlobal>,
) {
self.activate_child(child);
child
.node
.clone()
.node_do_focus(seat, Direction::Unspecified);
}
fn activate_child2(self: &Rc<Self>, child: &NodeRef<ContainerChild>, preserve_focus: bool) { fn activate_child2(self: &Rc<Self>, child: &NodeRef<ContainerChild>, preserve_focus: bool) {
if let Some(mc) = self.mono_child.get() { if let Some(mc) = self.mono_child.get() {
if mc.node.node_id() == child.node.node_id() { if mc.node.node_id() == child.node.node_id() {
@ -1519,7 +1534,7 @@ impl ContainerNode {
fn button( fn button(
self: Rc<Self>, self: Rc<Self>,
id: CursorType, id: CursorType,
_seat: &Rc<WlSeatGlobal>, seat: &Rc<WlSeatGlobal>,
_time_usec: u64, _time_usec: u64,
pressed: bool, pressed: bool,
button: u32, button: u32,
@ -1549,7 +1564,7 @@ impl ContainerNode {
if let Some(child) = children.get(&child_id) { if let Some(child) = children.get(&child_id) {
let child_ref = child.to_ref(); let child_ref = child.to_ref();
drop(children); drop(children);
self.activate_child(&child_ref); self.activate_child_from_input(&child_ref, seat);
} }
return; return;
} }
@ -2066,31 +2081,33 @@ impl Node for ContainerNode {
self.button(id, seat, time_usec, state == ButtonState::Pressed, button); self.button(id, seat, time_usec, state == ButtonState::Pressed, button);
} }
fn node_on_axis_event(self: Rc<Self>, _seat: &Rc<WlSeatGlobal>, event: &PendingScroll) { fn node_on_axis_event(self: Rc<Self>, seat: &Rc<WlSeatGlobal>, event: &PendingScroll) {
if self.mono_child.is_none() { if self.mono_child.is_none() {
return; return;
} }
// Use vertical scroll (index 1) to switch tabs. let steps = match self.scroll.handle(event) {
let v = match event.v120[1].get() { Some(steps) => steps,
Some(v) if v != 0 => v,
_ => return, _ => return,
}; };
let mono = match self.mono_child.get() { let mut target = match self.mono_child.get() {
Some(m) => m, Some(m) => m,
None => return, None => return,
}; };
let next = if v > 0 { let current_id = target.node.node_id();
// Scroll down → next tab. for _ in 0..steps.abs() {
mono.next().or_else(|| self.children.first()) let next = if steps > 0 {
target.next().or_else(|| self.children.first())
} else { } else {
// Scroll up → previous tab. target.prev().or_else(|| self.children.last())
mono.prev().or_else(|| self.children.last())
}; };
if let Some(next) = next { match next {
if next.node.node_id() != mono.node.node_id() { Some(next) => target = next,
self.activate_child(&next); None => break,
} }
} }
if target.node.node_id() != current_id {
self.activate_child_from_input(&target, seat);
}
} }
fn node_on_leave(&self, seat: &WlSeatGlobal) { fn node_on_leave(&self, seat: &WlSeatGlobal) {
@ -2616,6 +2633,9 @@ impl ToplevelNodeBase for ContainerNode {
if let Some(last) = self.focus_history.last() { if let Some(last) = self.focus_history.last() {
return last.node.clone().tl_last_active_child(); return last.node.clone().tl_last_active_child();
} }
if let Some(last) = self.children.last() {
return last.node.clone().tl_last_active_child();
}
self self
} }

View file

@ -8,18 +8,25 @@ use {
renderer::Renderer, renderer::Renderer,
state::State, state::State,
tree::{ tree::{
FindTreeResult, FindTreeUsecase, FoundNode, Node, NodeId, NodeLayerLink, NodeLocation, Direction, FindTreeResult, FindTreeUsecase, FoundNode, Node, NodeId, NodeLayerLink,
OutputNode, StackedNode, TileDragDestination, WorkspaceDragDestination, NodeLocation, OutputNode, StackedNode, TileDragDestination, WorkspaceDragDestination,
WorkspaceNodeId, walker::NodeVisitor, WorkspaceNodeId, walker::NodeVisitor,
}, },
utils::{copyhashmap::CopyHashMap, linkedlist::LinkedList}, 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 struct DisplayNode {
pub id: NodeId, pub id: NodeId,
pub extents: Cell<Rect>, pub extents: Cell<Rect>,
visible: Cell<bool>,
suspend_restore_kb_foci: RefCell<Vec<(Rc<WlSeatGlobal>, Weak<dyn Node>)>>,
pub outputs: CopyHashMap<ConnectorId, Rc<OutputNode>>, pub outputs: CopyHashMap<ConnectorId, Rc<OutputNode>>,
pub stacked: Rc<LinkedList<Rc<dyn StackedNode>>>, pub stacked: Rc<LinkedList<Rc<dyn StackedNode>>>,
pub stacked_above_layers: Rc<LinkedList<Rc<dyn StackedNode>>>, pub stacked_above_layers: Rc<LinkedList<Rc<dyn StackedNode>>>,
@ -31,6 +38,8 @@ impl DisplayNode {
let slf = Self { let slf = Self {
id, id,
extents: Default::default(), extents: Default::default(),
visible: Default::default(),
suspend_restore_kb_foci: Default::default(),
outputs: Default::default(), outputs: Default::default(),
stacked: Default::default(), stacked: Default::default(),
stacked_above_layers: Default::default(), stacked_above_layers: Default::default(),
@ -71,6 +80,17 @@ impl DisplayNode {
pub fn update_visible(&self, state: &State) { pub fn update_visible(&self, state: &State) {
let visible = state.root_visible(); 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() { for output in self.outputs.lock().values() {
output.update_visible(); output.update_visible();
} }
@ -82,6 +102,20 @@ impl DisplayNode {
for seat in state.globals.seats.lock().values() { for seat in state.globals.seats.lock().values() {
seat.set_visible(visible); 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 { if visible {
state.damage(self.extents.get()); state.damage(self.extents.get());
} }

View file

@ -460,9 +460,15 @@ impl OutputNode {
} }
self.lock_surface.take(); self.lock_surface.take();
self.jay_outputs.clear(); self.jay_outputs.clear();
self.screencasts.clear(); for screencast in self.screencasts.lock().drain_values() {
self.screencopies.clear(); screencast.do_destroy();
self.ext_copy_sessions.clear(); }
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.ext_workspace_groups.clear();
self.latch_event.clear(); self.latch_event.clear();
self.vblank_event.clear(); self.vblank_event.clear();

View file

@ -1323,3 +1323,54 @@ pub fn toplevel_set_workspace(state: &Rc<State>, tl: Rc<dyn ToplevelNode>, ws: &
tl.tl_set_fullscreen(true, Some(ws.clone())); tl.tl_set_fullscreen(true, Some(ws.clone()));
} }
} }
/// 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<dyn ToplevelNode>) -> bool {
if tl.node_is_placeholder() {
return false;
}
let data = tl.tl_data();
let workspace = data.workspace.get();
if data.is_fullscreen.get() {
tl.clone().tl_set_fullscreen(false, None);
if data.is_fullscreen.get() {
return false;
}
}
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();
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) = &workspace {
for seat in kb_foci {
workspace
.clone()
.node_do_focus(&seat, Direction::Unspecified);
}
}
true
}
/// Maps a parked scratchpad window back onto `ws`. Scratchpad windows always
/// return floating, regardless of how they were laid out before parking.
pub fn toplevel_restore_from_scratchpad(
state: &Rc<State>,
tl: Rc<dyn ToplevelNode>,
ws: &Rc<WorkspaceNode>,
) {
let (width, height) = tl.tl_data().float_size(ws);
state.map_floating(tl.clone(), width, height, ws, None);
}

View file

@ -64,6 +64,9 @@ pub enum SimpleCommand {
SetFloating(bool), SetFloating(bool),
ToggleFullscreen, ToggleFullscreen,
SetFullscreen(bool), SetFullscreen(bool),
SendToScratchpad,
ToggleScratchpad,
CycleScratchpad,
Forward(bool), Forward(bool),
EnableWindowManagement(bool), EnableWindowManagement(bool),
SetFloatAboveFullscreen(bool), SetFloatAboveFullscreen(bool),
@ -130,6 +133,15 @@ pub enum Action {
MoveToWorkspace { MoveToWorkspace {
name: String, name: String,
}, },
SendToScratchpad {
name: String,
},
ToggleScratchpad {
name: String,
},
CycleScratchpad {
name: String,
},
Multi { Multi {
actions: Vec<Action>, actions: Vec<Action>,
}, },

View file

@ -41,6 +41,7 @@ pub mod modified_keysym;
mod output; mod output;
mod output_match; mod output_match;
mod repeat_rate; mod repeat_rate;
mod scratchpad;
pub mod shortcuts; pub mod shortcuts;
mod simple_im; mod simple_im;
mod status; mod status;

View file

@ -117,6 +117,9 @@ impl ActionParser<'_> {
"toggle-fullscreen" => ToggleFullscreen, "toggle-fullscreen" => ToggleFullscreen,
"enter-fullscreen" => SetFullscreen(true), "enter-fullscreen" => SetFullscreen(true),
"exit-fullscreen" => SetFullscreen(false), "exit-fullscreen" => SetFullscreen(false),
"send-to-scratchpad" => SendToScratchpad,
"toggle-scratchpad" => ToggleScratchpad,
"cycle-scratchpad" => CycleScratchpad,
"focus-parent" => FocusParent, "focus-parent" => FocusParent,
"close" => Close, "close" => Close,
"disable-pointer-constraint" => DisablePointerConstraint, "disable-pointer-constraint" => DisablePointerConstraint,
@ -222,6 +225,33 @@ impl ActionParser<'_> {
Ok(Action::MoveToWorkspace { name }) Ok(Action::MoveToWorkspace { name })
} }
fn parse_send_to_scratchpad(&mut self, ext: &mut Extractor<'_>) -> ParseResult<Self> {
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<Self> {
let name = ext
.extract(opt(str("name")))?
.map(|name| name.value)
.unwrap_or("")
.to_string();
Ok(Action::ToggleScratchpad { name })
}
fn parse_cycle_scratchpad(&mut self, ext: &mut Extractor<'_>) -> ParseResult<Self> {
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<Self> { fn parse_configure_connector(&mut self, ext: &mut Extractor<'_>) -> ParseResult<Self> {
let con = ext let con = ext
.extract(val("connector"))? .extract(val("connector"))?
@ -551,6 +581,9 @@ impl Parser for ActionParser<'_> {
"switch-to-vt" => self.parse_switch_to_vt(&mut ext), "switch-to-vt" => self.parse_switch_to_vt(&mut ext),
"show-workspace" => self.parse_show_workspace(&mut ext), "show-workspace" => self.parse_show_workspace(&mut ext),
"move-to-workspace" => self.parse_move_to_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),
"cycle-scratchpad" => self.parse_cycle_scratchpad(&mut ext),
"configure-connector" => self.parse_configure_connector(&mut ext), "configure-connector" => self.parse_configure_connector(&mut ext),
"configure-input" => self.parse_configure_input(&mut ext), "configure-input" => self.parse_configure_input(&mut ext),
"configure-output" => self.parse_configure_output(&mut ext), "configure-output" => self.parse_configure_output(&mut ext),

View file

@ -28,6 +28,7 @@ use {
log_level::LogLevelParser, log_level::LogLevelParser,
output::OutputsParser, output::OutputsParser,
repeat_rate::RepeatRateParser, repeat_rate::RepeatRateParser,
scratchpad::ScratchpadsParser,
shortcuts::{ shortcuts::{
ComplexShortcutsParser, ShortcutsParser, ShortcutsParserError, ComplexShortcutsParser, ShortcutsParser, ShortcutsParserError,
parse_modified_keysym_str, 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 { Ok(Config {
keymap, keymap,
repeat_rate, repeat_rate,

View file

@ -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<String>, Spanned<Value>>,
) -> ParseResult<Self> {
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<Scratchpad>;
type Error = ScratchpadParserError;
const EXPECTED: &'static [DataType] = &[DataType::Table, DataType::Array];
fn parse_array(&mut self, _span: Span, array: &[Spanned<Value>]) -> ParseResult<Self> {
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<String>, Spanned<Value>>,
) -> ParseResult<Self> {
log::warn!(
"`scratchpads` value should be an array: {}",
self.0.error3(span)
);
ScratchpadParser(self.0)
.parse_table(span, table)
.map(|v| vec![v])
}
}

View file

@ -15,7 +15,7 @@ use {
config::{ config::{
Action, AnimationCurveConfig, ClientRule, Config, ConfigConnector, ConfigDrmDevice, Action, AnimationCurveConfig, ClientRule, Config, ConfigConnector, ConfigDrmDevice,
ConfigKeymap, ConnectorMatch, DrmDeviceMatch, Exec, Input, InputMatch, Output, 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}, rules::{MatcherTemp, RuleMapper},
shortcuts::ModeState, shortcuts::ModeState,
@ -173,6 +173,9 @@ impl Action {
SimpleCommand::Move(dir) => window_or_seat!(s, s.move_(dir)), SimpleCommand::Move(dir) => window_or_seat!(s, s.move_(dir)),
SimpleCommand::ToggleFullscreen => window_or_seat!(s, s.toggle_fullscreen()), SimpleCommand::ToggleFullscreen => window_or_seat!(s, s.toggle_fullscreen()),
SimpleCommand::SetFullscreen(b) => window_or_seat!(s, s.set_fullscreen(b)), 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::FocusParent => b.new(move || s.focus_parent()),
SimpleCommand::Close => window_or_seat!(s, s.close()), SimpleCommand::Close => window_or_seat!(s, s.close()),
SimpleCommand::DisablePointerConstraint => { SimpleCommand::DisablePointerConstraint => {
@ -306,6 +309,9 @@ impl Action {
let workspace = get_workspace(&name); let workspace = get_workspace(&name);
window_or_seat!(s, s.set_workspace(workspace)) 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::CycleScratchpad { name } => b.new(move || s.cycle_scratchpad(&name)),
Action::ConfigureConnector { con } => b.new(move || { Action::ConfigureConnector { con } => b.new(move || {
for c in connectors() { for c in connectors() {
if con.match_.matches(c) { if con.match_.matches(c) {
@ -1457,6 +1463,43 @@ fn load_config(initial_load: bool, auto_reload: bool, persistent: &Rc<Persistent
window: Default::default(), window: Default::default(),
}); });
state.clear_modes_after_reload(); state.clear_modes_after_reload();
// Desugar `[[scratchpads]]` into spawn-on-graphics-init plus an internal
// window rule that parks the spawned window. Each spawned process gets a
// unique tag so only its own windows are captured, never other windows of
// the same application.
if !config.scratchpads.is_empty() {
let mut spawn_actions = vec![];
for (i, sp) in config.scratchpads.drain(..).enumerate() {
let Some(mut exec) = sp.exec else {
continue;
};
let tag = exec
.tag
.clone()
.unwrap_or_else(|| format!("__scratchpad.{i}.{}", sp.name));
exec.tag = Some(tag.clone());
spawn_actions.push(Action::Exec { exec });
config.window_rules.push(WindowRule {
name: None,
match_: WindowMatch {
tag: Some(tag),
..Default::default()
},
action: Some(Action::SendToScratchpad { name: sp.name }),
latch: None,
auto_focus: None,
initial_tile_state: None,
});
}
if !spawn_actions.is_empty() {
let mut actions = Vec::with_capacity(spawn_actions.len() + 1);
if let Some(existing) = config.on_graphics_initialized.take() {
actions.push(existing);
}
actions.extend(spawn_actions);
config.on_graphics_initialized = Some(Action::Multi { actions });
}
}
let (client_rules, client_rule_mapper) = state.create_rules(&config.client_rules); let (client_rules, client_rule_mapper) = state.create_rules(&config.client_rules);
persistent.client_rules.set(client_rules); persistent.client_rules.set(client_rules);
*state.persistent.client_rule_mapper.borrow_mut() = Some(client_rule_mapper); *state.persistent.client_rule_mapper.borrow_mut() = Some(client_rule_mapper);

View file

@ -162,6 +162,54 @@
"name" "name"
] ]
}, },
{
"description": "Sends the currently focused window to a scratchpad and hides it.\n\nA scratchpad can hold any number of windows. If `name` is omitted, the\ndefault 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.\nOnly one window of a scratchpad is shown at a time, and scratchpad windows are\nalways shown floating. If `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": "Cycles through the windows of a scratchpad, one at a time.\n\nWith no window shown, the first window is brought up. Each further invocation\nhides the current window and shows the next; after the last window the\nscratchpad is hidden again. Scratchpad windows are always shown floating.\nIf `name` is omitted, the default scratchpad is used.\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-minus = { type = \"cycle-scratchpad\", name = \"terminal\" }\n ```\n",
"type": "object",
"properties": {
"type": {
"const": "cycle-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", "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", "type": "object",
@ -1240,6 +1288,14 @@
"egui": { "egui": {
"description": "Sets the egui settings of the compositor.\n", "description": "Sets the egui settings of the compositor.\n",
"$ref": "#/$defs/Egui" "$ref": "#/$defs/Egui"
},
"scratchpads": {
"type": "array",
"description": "An array of pre-configured scratchpads.\n\nEach entry launches a program when the graphics are first initialized and\nimmediately parks its window in the named scratchpad. The window is captured\nvia a unique tag attached to the spawned process, so other windows of the\nsame application are never affected.\n\nUse a `toggle-scratchpad` or `cycle-scratchpad` action to bring the windows\nup; they are always shown floating.\n\n- Example:\n\n ```toml\n [[scratchpads]]\n name = \"term\"\n exec = \"foot\"\n\n [[scratchpads]]\n name = \"notes\"\n exec = [\"obsidian\"]\n ```\n",
"items": {
"description": "",
"$ref": "#/$defs/Scratchpad"
}
} }
}, },
"required": [] "required": []
@ -2054,6 +2110,23 @@
}, },
"required": [] "required": []
}, },
"Scratchpad": {
"description": "A pre-configured scratchpad whose program is launched at startup and parked\nin the scratchpad.\n\n- Example:\n\n ```toml\n [[scratchpads]]\n name = \"term\"\n exec = \"foot\"\n ```\n",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name of the scratchpad that the spawned window is parked in."
},
"exec": {
"description": "The program to launch when the graphics are first initialized.\n\nIf omitted, no program is launched and the scratchpad is only created on\ndemand by `send-to-scratchpad`.\n",
"$ref": "#/$defs/Exec"
}
},
"required": [
"name"
]
},
"SimpleActionName": { "SimpleActionName": {
"type": "string", "type": "string",
"description": "The name of a `simple` Action.\n\nWhen used inside a window rule, the following actions apply to the matched window\ninstead fo the focused window:\n\n- `move-left`\n- `move-down`\n- `move-up`\n- `move-right`\n- `toggle-fullscreen`\n- `enter-fullscreen`\n- `exit-fullscreen`\n- `close`\n- `toggle-floating`\n- `float`\n- `tile`\n- `toggle-float-pinned`\n- `pin-float`\n- `unpin-float`\n\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-q = \"quit\"\n ```\n", "description": "The name of a `simple` Action.\n\nWhen used inside a window rule, the following actions apply to the matched window\ninstead fo the focused window:\n\n- `move-left`\n- `move-down`\n- `move-up`\n- `move-right`\n- `toggle-fullscreen`\n- `enter-fullscreen`\n- `exit-fullscreen`\n- `close`\n- `toggle-floating`\n- `float`\n- `tile`\n- `toggle-float-pinned`\n- `pin-float`\n- `unpin-float`\n\n\n- Example:\n\n ```toml\n [shortcuts]\n alt-q = \"quit\"\n ```\n",
@ -2078,6 +2151,9 @@
"toggle-fullscreen", "toggle-fullscreen",
"enter-fullscreen", "enter-fullscreen",
"exit-fullscreen", "exit-fullscreen",
"send-to-scratchpad",
"toggle-scratchpad",
"cycle-scratchpad",
"focus-parent", "focus-parent",
"close", "close",
"disable-pointer-constraint", "disable-pointer-constraint",

View file

@ -286,6 +286,76 @@ This table is a tagged union. The variant is determined by the `type` field. It
The value of this field should be a string. The value of this field should be a string.
- `send-to-scratchpad`:
Sends the currently focused window to a scratchpad and hides it.
A scratchpad can hold any number of windows. 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.
Only one window of a scratchpad is shown at a time, and scratchpad windows are
always shown floating. 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.
- `cycle-scratchpad`:
Cycles through the windows of a scratchpad, one at a time.
With no window 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.
If `name` is omitted, the default scratchpad is used.
- Example:
```toml
[shortcuts]
alt-minus = { type = "cycle-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`: - `move-to-output`:
Moves a workspace to a different output. Moves a workspace to a different output.
@ -1007,6 +1077,7 @@ The string should have one of the following values:
supported plan exists. supported plan exists.
<a name="types-Animations"></a> <a name="types-Animations"></a>
### `Animations` ### `Animations`
@ -2601,6 +2672,32 @@ The table has the following fields:
The value of this field should be a [Egui](#types-Egui). The value of this field should be a [Egui](#types-Egui).
- `scratchpads` (optional):
An array of pre-configured scratchpads.
Each entry launches a program when the graphics are first initialized and
immediately parks its window in the named scratchpad. The window is captured
via a unique tag attached to the spawned process, so other windows of the
same application are never affected.
Use a `toggle-scratchpad` or `cycle-scratchpad` action to bring the windows
up; they are always shown floating.
- Example:
```toml
[[scratchpads]]
name = "term"
exec = "foot"
[[scratchpads]]
name = "notes"
exec = ["obsidian"]
```
The value of this field should be an array of [Scratchpads](#types-Scratchpad).
<a name="types-Connector"></a> <a name="types-Connector"></a>
### `Connector` ### `Connector`
@ -4534,6 +4631,40 @@ The table has the following fields:
The value of this field should be a string. The value of this field should be a string.
<a name="types-Scratchpad"></a>
### `Scratchpad`
A pre-configured scratchpad whose program is launched at startup and parked
in the scratchpad.
- Example:
```toml
[[scratchpads]]
name = "term"
exec = "foot"
```
Values of this type should be tables.
The table has the following fields:
- `name` (required):
The name of the scratchpad that the spawned window is parked in.
The value of this field should be a string.
- `exec` (optional):
The program to launch when the graphics are first initialized.
If omitted, no program is launched and the scratchpad is only created on
demand by `send-to-scratchpad`.
The value of this field should be a [Exec](#types-Exec).
<a name="types-SimpleActionName"></a> <a name="types-SimpleActionName"></a>
### `SimpleActionName` ### `SimpleActionName`
@ -4649,6 +4780,18 @@ The string should have one of the following values:
Makes the currently focused window windowed. Makes the currently focused window windowed.
- `send-to-scratchpad`:
Sends the currently focused window to the default scratchpad.
- `toggle-scratchpad`:
Toggles the default scratchpad.
- `cycle-scratchpad`:
Cycles through the windows of the default scratchpad.
- `focus-parent`: - `focus-parent`:
Focus the parent of the currently focused window. Focus the parent of the currently focused window.
@ -5830,3 +5973,5 @@ The table has the following fields:
The scaling mode of X windows. The scaling mode of X windows.
The value of this field should be a [XScalingMode](#types-XScalingMode). The value of this field should be a [XScalingMode](#types-XScalingMode).

View file

@ -345,6 +345,64 @@ Action:
description: The name of the workspace. description: The name of the workspace.
required: true required: true
kind: string kind: string
send-to-scratchpad:
description: |
Sends the currently focused window to a scratchpad and hides it.
A scratchpad can hold any number of windows. 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.
Only one window of a scratchpad is shown at a time, and scratchpad windows are
always shown floating. 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
cycle-scratchpad:
description: |
Cycles through the windows of a scratchpad, one at a time.
With no window 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.
If `name` is omitted, the default scratchpad is used.
- Example:
```toml
[shortcuts]
alt-minus = { type = "cycle-scratchpad", name = "terminal" }
```
fields:
name:
description: The name of the scratchpad.
required: false
kind: string
move-to-output: move-to-output:
description: | description: |
Moves a workspace to a different output. Moves a workspace to a different output.
@ -1076,6 +1134,12 @@ SimpleActionName:
description: Makes the currently focused window fullscreen. description: Makes the currently focused window fullscreen.
- value: exit-fullscreen - value: exit-fullscreen
description: Makes the currently focused window windowed. 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: cycle-scratchpad
description: Cycles through the windows of the default scratchpad.
- value: focus-parent - value: focus-parent
description: Focus the parent of the currently focused window. description: Focus the parent of the currently focused window.
- value: close - value: close
@ -3246,6 +3310,61 @@ Config:
required: false required: false
description: | description: |
Sets the egui settings of the compositor. Sets the egui settings of the compositor.
scratchpads:
kind: array
items:
ref: Scratchpad
required: false
description: |
An array of pre-configured scratchpads.
Each entry launches a program when the graphics are first initialized and
immediately parks its window in the named scratchpad. The window is captured
via a unique tag attached to the spawned process, so other windows of the
same application are never affected.
Use a `toggle-scratchpad` or `cycle-scratchpad` action to bring the windows
up; they are always shown floating.
- Example:
```toml
[[scratchpads]]
name = "term"
exec = "foot"
[[scratchpads]]
name = "notes"
exec = ["obsidian"]
```
Scratchpad:
kind: table
description: |
A pre-configured scratchpad whose program is launched at startup and parked
in the scratchpad.
- Example:
```toml
[[scratchpads]]
name = "term"
exec = "foot"
```
fields:
name:
kind: string
required: true
description: The name of the scratchpad that the spawned window is parked in.
exec:
ref: Exec
required: false
description: |
The program to launch when the graphics are first initialized.
If omitted, no program is launched and the scratchpad is only created on
demand by `send-to-scratchpad`.
Idle: Idle: