Toggle special workspaces

I wonder if someone can help me with this. I have two key bindings:

hl.bind(mainMod .. " + T",         hl.dsp.workspace.toggle_special("thunar"))
hl.bind(mainMod .. " + RETURN",    hl.dsp.workspace.toggle_special("terminal"))

And two window rules:

hl.workspace_rule({ workspace = "special:thunar", on_created_empty = "uwsm-app thunar })
hl.workspace_rule({ workspace = "special:terminal", on_created_empty = "uwsm-app alacritty" })

And for some reason, I cannot work out why, the special workspace with thunar toggles perfectly, but the mainMod + RETURN keybinding just creates new special workspaces with a terminal, doesn’t toggle. Why is that?

is the missing quote just in your msg here? or also in your config?

hl.workspace_rule({ workspace = "special:thunar", on_created_empty = "uwsm-app thunar <--MISSING QUOTE})
hl.workspace_rule({ workspace = "special:terminal", on_created_empty = "uwsm-app alacritty" })

Sorry, yes that is just in the quote, not in the config.

Its very weird… i just tried the exact thing you posted in my config, (with the quote fixed) and both workspaces toggle properly.
Do you have some sort of conflicting rule somewhere that keeps either alacritty focused, or the special workspace for alacritty focused?

I’d do a grep through ur config files for alacritty as a first step to troubleshooting it

These are the other windowrules for alacritty:

hl.window_rule({match = {class = "Alacritty" }, float = true})
hl.window_rule({match = {class = "Alacritty" }, size = "1000 600"})
hl.window_rule({match = {class = "Alacritty" }, opacity = 0.8})

but I don’t think they could be the cause. And I have commented out any other mention of alacritty. Changes should take effect immediately after saving, is that correct?

Yes changes should take effect immediately, but in the past I have found that things like event listeners hang around after being commented/removed until a Hyprland restart

It turns out that the offending windowrule was the following, which had been suggested to me by Sanjay:

hl.on ("window.open",function()
	local w = hl.get_active_window()
	local a = hl.get_active_workspace()
	if w.initial_title ~= "Nextcloud - Thunar" and w.class ~= "thunar" and w.initial_title ~= nil and w.class ~= nil then
		hl.dispatch(hl.dsp.window.move({ workspace = a.id }))
	end
end)

It makes sure that files that are opened from the special workspace with thunar go the workspace below it. But apparently this conflicts with the toggling of the other special workspace with alacritty. Any idea why? Also, now I need another way to make sure files opened from thunar go the workspace below it.

And could it have something to do with the error that that windowrule gives, ‘attempt to index a nil value’?

@sanjay do you have any idea?

@derxen I had a little play around in my config, and might have something that solves you’re problem?
Its pretty verbose, so i would suggest you split it out from your main config as follows:

  1. create ~/.config/hypr/utils.lua
  2. populate it as follows:
local M = {}

---Checks if a table contains a specific value.
---@param tbl table the table to check
---@param value any the value to look for
---@return boolean result true if the value is found in the table, false otherwise
function M.tbl_contains(tbl, value)
  for _, v in pairs(tbl) do
    if v == value then
      return true
    end
  end
  return false
end

--- Filters a table based on a predicate function
--- @param tbl table The table to filter
--- @param predicate fun(value: any): boolean The predicate function to use for filtering the table
--- @return table result The filtered table
function M.filter_table(tbl, predicate)
  local filtered = {}
  for _, v in ipairs(tbl) do
    if predicate(v) then
      table.insert(filtered, v)
    end
  end
  return filtered
end

--- Queries a hyprland window / workspace object based on a selector
--- @param obj HL.Window|HL.Workspace The hyprland window or workspace object to query
--- @param selector HL.WindowSelector|HL.WorkspaceSelector The selector string or table to use for querying the object
--- @return boolean result True if the object matches the selector, false otherwise
function M.hl_match_query(obj, selector)
  if type(selector) == 'number' then
    return obj.pid == selector or obj.stable_id == selector or obj.id == selector
  end
  if type(selector) == 'string' then
    if obj.name == selector then
      return true
    end
    local key, value = selector:match('^(.-):(.+)$')
    if key and value then
      return obj[key] == value
    end
  end
  if type(selector) == 'table' then
    for k, v in pairs(selector) do
      if obj[k] ~= v then
        return false
      end
    end
    return true
  end
  if type(selector) == 'userdata' then
    return obj == selector
  end
  return false
end

--- @class DemoteOptions
--- @field launched_from? HL.WindowSelector The [window selector](https://wiki.hypr.land/Configuring/Basics/Dispatchers/#window) of the window that is the file manager launching the files we want to demote (if nil, then all windows capable of launching a file will be affected)
--- @field exclusions? HL.WindowSelector[] A list of [window selectors](https://wiki.hypr.land/Configuring/Basics/Dispatchers/#window) that should not be demoted from the special workspace
--- @field demote_from? HL.WorkspaceSelector A [workspace selector](https://wiki.hypr.land/Configuring/Basics/Dispatchers/#workspace-selectors) that should be monitored for windows to demote from the special workspace

--- Demotes windows from special workspaces to the active workspace if they are not excepted by the criteria in the options tables exlusions property
--- @param window HL.Window The [window](https://wiki.hypr.land/Configuring/Basics/Dispatchers/#window) to demote from the special workspace
--- @param opts? DemoteOptions The options to use for demoting the window from the special workspace
--- @return nil
function M.demote_from_special_workspace(window, opts)
  if window == nil then
    return
  end
  opts = opts or {}
  opts.exclusions = opts.exclusions or {}
  opts.demote_from = opts.demote_from and hl.get_workspace(opts.demote_from) or hl.get_active_special_workspace()

  if not opts.demote_from then
    return
  end

  local active_window = hl.get_active_window()
  local launched_from_selector = opts.launched_from
  local demote_from_windows = hl.get_workspace_windows(opts.demote_from)

  demote_from_windows = M.filter_table(demote_from_windows, function(w)
    return launched_from_selector == nil or M.hl_match_query(w, launched_from_selector)
  end)

  if #demote_from_windows == 0 then
    return
  end

  if not M.tbl_contains(demote_from_windows, active_window) then
    return
  end

  for _, exclusion in ipairs(opts.exclusions) do
    if M.hl_match_query(window, exclusion) then
      return
    end
  end
  if window.workspace == opts.demote_from then
    hl.dispatch(hl.dsp.window.move({ window = window, workspace = hl.get_active_workspace() }))
  end
end

return M
  1. update your window rule to look like this:
local demote_opts = {}

-- some example ways of targeting windows that should be excluded from
-- being demoted to the workspace below the special one
demote_opts.exclusions = {
  'class:thunar',
  'class:nemo',
  { class = 'thunar', intial_title = 'Nextcloud - Thunar' },
  { initial_title = nil, class = nil },
  hl.get_window('some_window_selector'),
}

-- if launched_from is ommitted from the opts table, then any app capable of
-- launching a file will demote the non-excluded windows to the workspace below
-- the special one
demote_opts.launched_from = 'class:thunar'

-- demote_from should be a selector that matches your special workspace
demote_opts.demote_from = 'name:special:files'


hl.on('window.open_early', function(w)
  require('utils').demote_from_special_workspace(w, demote_opts)
end)

a more simplified option without all the commenting would be:

local demote_opts = {
  exclusions = { 'class:thunar' },
  launched_from = 'class:thunar',
  demote_from = 'name:special:files',
}

hl.on('window.open_early', function(w)
  require('utils').demote_from_special_workspace(w, demote_opts)
end)

Thank you so much Vito. That’s really kind. I’m going tp study this and try it out this weekend. I’ll let you know.

Quick question: is function(w) the function in the original windowrule? So w = hl.get_active_window()

No, w is the window that triggered the event.
When hyprland triggers the events for window based event handlers like window.open or window.open_early it passes the window object that triggers it to any functions that are attached via the hl.on listeners…

In the code above, I used open_early rather than open in order to ensure that hl.get_active_window in the demote_from_special_workspace function will capture the window that is being double-clicked to launch something, (Thunar in your case), rather than the window that is launched that triggers the event.

So essentially, active_window in the M.demote_from_special_workspace function is the window you are double-cicking on to launch something (thunar) and w is the window that is about to launch, passed by hyprland, to the callback, which then passes it to demote_from_special_workspace as window.

make sense?

Yes, makes perfect sense. Thank you. I’m going to try out the code today or tomorrow.

Amazing! It works flawlessly. I’m going to study your code further to familiarise myself with lua. It’s going to be a great learning experience. Thank you so much, I really appreciate your help.

My pleasure mate. Happy to explain anything you don’t understand if you like. Might take a while to respond, as i’m guessing our timezones don’t line up very well, but yea, if you have questions, feel free to msg me :slight_smile:

As I mentioned in the member chat: occasionally the special workspace misbehaves. Just now, for example, I opened a file from thunar on the special workspace, thunar itself disappeared (that is normal), but the document is now on all workspaces. If I toggle the special workspace the document disappears and the whatever is on the workspace reappears. When I close the document thunar reappears, as if it was hidden underneath the document.

And logging out and back in to the hyprland session solves it.

I’ll have a look and see if I can reproduce it to work out what’s happening.
Can you tell me what file type the document was, and what app opened it?

This happened both with pdf and odt files, opened from thunar in its special workspace, by document viewer and libreoffice writer respectively.