feat: scratchpads
All checks were successful
Nix Build / nix build (push) Successful in 23s

This commit is contained in:
atagen 2026-02-25 17:04:50 +11:00
parent 4c748be113
commit 175d044930
3 changed files with 125 additions and 43 deletions

View file

@ -47,10 +47,11 @@ impl NiriTag {
ts.occupied = false;
}
});
if let Some(old) = self.tags.get(&old_tag) {
if old_tag != 0 && !old.occupied {
self.fire_event(TagEvent::TagEmpty(old_tag)).await;
}
if let Some(old) = self.tags.get(&old_tag)
&& old_tag != 0
&& !old.occupied
{
self.fire_event(TagEvent::TagEmpty(old_tag)).await;
};
}
@ -208,14 +209,14 @@ impl NiriTag {
affected_windows,
)
.await;
if let Some(focus) = focus {
if tag_visible {
tell(
&mut self.socket,
Request::Action(Action::FocusWindow { id: focus }),
)
.await?;
}
if let Some(focus) = focus
&& tag_visible
{
tell(
&mut self.socket,
Request::Action(Action::FocusWindow { id: focus }),
)
.await?;
}
}
TagExclusive(t) => {
@ -293,38 +294,68 @@ impl NiriTag {
}
Receivable::TagCmd(cmd) => match cmd {
TagCmd::AddTagToWin(t) => {
let wid = self.get_focused_window().await?.id;
self.change_window_tag(wid, Some(t)).await?;
let entry = self.tags.entry(t).or_default();
if entry.windows.len() == 1 && self.config.activation_on_fill {
entry.enabled = true;
self.fire_event(TagEvent::TagEnabled(t)).await;
&[Tag(t), Window(wid)]
let win = self.get_focused_window().await?;
if win
.app_id
.as_ref()
.is_some_and(|name| self.config.scratchpads.contains_key(name))
{
&[]
} else {
&[Window(wid)]
let wid = win.id;
self.change_window_tag(wid, Some(t)).await?;
let entry = self.tags.entry(t).or_default();
if entry.windows.len() == 1 && self.config.activation_on_fill {
entry.enabled = true;
self.fire_event(TagEvent::TagEnabled(t)).await;
&[Tag(t), Window(wid)]
} else {
&[Window(wid)]
}
}
}
TagCmd::RemoveTagFromWin(_) => {
let wid = self.get_focused_window().await?.id;
self.change_window_tag(wid, None).await?;
&[Window(wid)]
let win = self.get_focused_window().await?;
if win
.app_id
.as_ref()
.is_some_and(|name| self.config.scratchpads.contains_key(name))
{
&[]
} else {
let wid = win.id;
self.change_window_tag(wid, None).await?;
&[Window(wid)]
}
}
TagCmd::ToggleTagOnWin(t) => {
let wid = self.get_focused_window().await?.id;
let new_tag = if *self.windows.entry(wid).or_insert(0) == t {
0
let win = self.get_focused_window().await?;
if win
.app_id
.as_ref()
.is_some_and(|name| self.config.scratchpads.contains_key(name))
{
&[]
} else {
t
};
self.change_window_tag(wid, Some(new_tag)).await?;
tracing::debug!("toggling {} to tag {}", wid, new_tag);
let entry = self.tags.entry(new_tag).or_default();
if new_tag != 0 && entry.windows.len() == 1 && self.config.activation_on_fill {
entry.enabled = true;
self.fire_event(TagEvent::TagEnabled(t)).await;
&[Tag(t), Window(wid)]
} else {
&[Window(wid)]
let wid = win.id;
let new_tag = if *self.windows.entry(wid).or_insert(0) == t {
0
} else {
t
};
self.change_window_tag(wid, Some(new_tag)).await?;
tracing::debug!("toggling {} to tag {}", wid, new_tag);
let entry = self.tags.entry(new_tag).or_default();
if new_tag != 0
&& entry.windows.len() == 1
&& self.config.activation_on_fill
{
entry.enabled = true;
self.fire_event(TagEvent::TagEnabled(t)).await;
&[Tag(t), Window(wid)]
} else {
&[Window(wid)]
}
}
}
@ -386,7 +417,26 @@ impl NiriTag {
use Event::*;
match ev {
WindowOpenedOrChanged { window } => {
self.windows.entry(window.id).or_insert(0);
let wid = window.id;
let current_tag = self.windows.get(&wid).copied();
// only reassign if window is new or still untagged
// this handles the common case where app_id: None arrives first
if current_tag.is_none() || current_tag == Some(0) {
if let Some(ref app_id) = window.app_id
&& let Some(&tag) = self.config.scratchpads.get(app_id)
{
tracing::debug!(
"scratchpad: auto-assigning wid={} app_id={} to tag {}",
wid,
app_id,
tag
);
self.change_window_tag(wid, Some(tag)).await?;
self.do_actions(&[TagAction::Window(wid)]).await?;
} else if current_tag.is_none() {
self.windows.insert(wid, 0);
}
}
Ok(())
}
WindowClosed { id } => {
@ -395,10 +445,10 @@ impl NiriTag {
ts.windows.remove(&id);
ts.occupied = !ts.windows.is_empty();
});
if let Some(tag) = self.tags.get(&t) {
if !tag.occupied {
self.fire_event(TagEvent::TagEmpty(t)).await;
}
if let Some(tag) = self.tags.get(&t)
&& !tag.occupied
{
self.fire_event(TagEvent::TagEmpty(t)).await;
}
}
Ok(())
@ -477,7 +527,6 @@ impl NiriTag {
(0..=self.config.prepopulate).for_each(|i| {
self.tags.entry(i).or_default();
});
loop {
let recvd: Receivable = future::or(
async { ev_rx.recv().await.map(Receivable::Event) },

View file

@ -60,6 +60,7 @@ pub struct Config {
pub strict: bool,
#[serde(default = "default_true")]
pub activation_on_fill: bool,
pub scratchpads: HashMap<String, u8>,
}
impl Default for Config {
@ -68,6 +69,7 @@ impl Default for Config {
prepopulate: 3,
strict: true,
activation_on_fill: true,
scratchpads: HashMap::new(),
}
}
}

View file

@ -8,9 +8,12 @@ let
inherit (lib)
mkEnableOption
mkPackageOption
mkOption
mkIf
getExe
types
;
inherit (types) attrsOf bool;
name = "Niri Tag Manager";
in
{
@ -20,6 +23,18 @@ in
nullable = true;
default = "niri-tag";
};
prepopulate = mkOption {
type = types.numbers.between 0 255;
default = 3;
};
scratchpads = mkOption {
type = attrsOf (types.numbers.between 1 255);
default = { };
};
strict = mkOption {
type = bool;
default = true;
};
};
config =
let
@ -40,6 +55,22 @@ in
PrivateTmp = true;
};
};
environment.etc."niri-tag/config.toml".source =
let
scratchpads =
let
contents = lib.mapAttrsToList (app: number: "${app} = ${toString number}\n") cfg.scratchpads;
in
lib.optionalString (lib.count <| lib.attrsToList cfg.scratchpads) ''
[scratchpads]
${contents}
'';
in
pkgs.writeTextFile "config.toml" ''
prepopulate = ${toString cfg.prepopulate}
strict = ${lib.boolToString cfg.strict}
${scratchpads}
'';
environment.systemPackages = [ cfg.package ];
};
}