386 lines
12 KiB
Lua
386 lines
12 KiB
Lua
-- Workspace modes for omarchy-hyprland-workspace-layout-toggle.
|
||
--
|
||
-- Hyprland has dwindle and scrolling as tiled layouts, but nothing that floats a
|
||
-- whole workspace: it reports a workspace's `tiledLayout` and takes an unknown
|
||
-- layout name in silence, falling back to dwindle. So floating is applied to the
|
||
-- windows themselves.
|
||
--
|
||
-- Ending the mode has to tile the windows the mode floated and only those, so
|
||
-- each one is tagged. The tag lives on the window: it dies with it, follows it
|
||
-- between workspaces, and survives the config reload that a monitor being
|
||
-- plugged in performs, none of which a table on this side would do.
|
||
--
|
||
-- Every window on a floating workspace also carries a second tag, whether the
|
||
-- mode floated it or it was floating already. Titlebars, shadows, and full
|
||
-- opacity follow that tag, so a dialog on a floating workspace can be dragged by
|
||
-- its titlebar too, and a window carried out to a tiled one loses it.
|
||
|
||
require("default.hypr.helpers")
|
||
local paths = require("default.hypr.paths")
|
||
|
||
local FLOATED_TAG = "omarchy-mode-floated"
|
||
local WORKSPACE_TAG = "omarchy-floating-workspace"
|
||
local TITLEBARS = "/usr/lib/omarchy-hyprland-titlebars/titlebars.so"
|
||
|
||
local M = {}
|
||
|
||
local modes = {}
|
||
local default_mode = nil
|
||
local watching = false
|
||
local cascade = 0
|
||
|
||
local function set_floating(window, floating)
|
||
hl.dispatch(hl.dsp.window.float({ action = floating and "on" or "off", window = window }))
|
||
end
|
||
|
||
local function set_claimed(window, claimed)
|
||
hl.dispatch(hl.dsp.window.tag({ tag = (claimed and "+" or "-") .. FLOATED_TAG, window = window }))
|
||
end
|
||
|
||
local function set_on_floating_workspace(window, on)
|
||
hl.dispatch(hl.dsp.window.tag({ tag = (on and "+" or "-") .. WORKSPACE_TAG, window = window }))
|
||
end
|
||
|
||
local function claimed()
|
||
local addresses = {}
|
||
|
||
for _, window in ipairs(hl.get_windows({ tag = FLOATED_TAG })) do
|
||
addresses[window.address] = true
|
||
end
|
||
|
||
return addresses
|
||
end
|
||
|
||
-- Special workspaces, such as the scratchpad, and named ones, which report a
|
||
-- negative id, keep whatever state a window arrived with.
|
||
local function floating_workspace(id)
|
||
id = tonumber(id)
|
||
|
||
if not id or id < 1 then
|
||
return false
|
||
end
|
||
|
||
local mode = modes[tostring(id)]
|
||
if mode then
|
||
return mode == "floating"
|
||
end
|
||
|
||
return default_mode == "floating"
|
||
end
|
||
|
||
local function anything_floats()
|
||
if default_mode == "floating" then
|
||
return true
|
||
end
|
||
|
||
for _, mode in pairs(modes) do
|
||
if mode == "floating" then
|
||
return true
|
||
end
|
||
end
|
||
|
||
return false
|
||
end
|
||
|
||
-- A window the mode floats opens at a comfortable size in the middle of its
|
||
-- monitor, each one stepped from the last so a workspace floated all at once does
|
||
-- not leave every window stacked exactly on the one beneath it.
|
||
local function place(window)
|
||
local monitor = window.monitor
|
||
if not monitor or not monitor.width then
|
||
return
|
||
end
|
||
|
||
local width = math.floor(monitor.width / monitor.scale)
|
||
local height = math.floor(monitor.height / monitor.scale)
|
||
|
||
if (monitor.transform or 0) % 2 == 1 then
|
||
width, height = height, width
|
||
end
|
||
|
||
local reserved = monitor.reserved or {}
|
||
width = width - (reserved.left or 0) - (reserved.right or 0)
|
||
height = height - (reserved.top or 0) - (reserved.bottom or 0)
|
||
|
||
hl.dispatch(hl.dsp.window.resize({
|
||
x = math.min(1100, math.floor(width * 0.64)),
|
||
y = math.min(760, math.floor(height * 0.68)),
|
||
relative = false,
|
||
window = window,
|
||
}))
|
||
hl.dispatch(hl.dsp.window.center({ window = window }))
|
||
|
||
cascade = (cascade + 1) % 5
|
||
hl.dispatch(hl.dsp.window.move({ x = cascade * 24 - 48, y = cascade * 24 - 48, relative = true, window = window }))
|
||
end
|
||
|
||
-- A window already floating on its own account -- an app with a float rule of
|
||
-- its own, a dialog, one popped out with SUPER + T -- is left unclaimed, so it
|
||
-- is still floating when the mode ends. A fullscreen window is left as it is.
|
||
--
|
||
-- Every tiled window is claimed before any of them is floated: floating one
|
||
-- member of a group floats the rest, and a single pass would then find those
|
||
-- already floating and leave them to be stranded when the mode ends.
|
||
local function float_for_mode(windows)
|
||
local claiming = {}
|
||
|
||
for _, window in ipairs(windows) do
|
||
set_on_floating_workspace(window, true)
|
||
|
||
if not window.floating and (window.fullscreen or 0) == 0 then
|
||
set_claimed(window, true)
|
||
table.insert(claiming, window)
|
||
end
|
||
end
|
||
|
||
for _, window in ipairs(claiming) do
|
||
set_floating(window, true)
|
||
place(window)
|
||
end
|
||
end
|
||
|
||
-- A pinned window is one the user asked to keep above everything, and tiling it
|
||
-- would unpin it as well, so the mode leaves it be.
|
||
local function tile_for_mode(window, mine)
|
||
set_on_floating_workspace(window, false)
|
||
|
||
if not mine[window.address] or window.pinned then
|
||
return
|
||
end
|
||
|
||
set_claimed(window, false)
|
||
set_floating(window, false)
|
||
end
|
||
|
||
-- The workspace is passed in when a window is still on its way to it.
|
||
local function arrange(windows, workspace)
|
||
local floating = {}
|
||
local mine = claimed()
|
||
|
||
for _, window in ipairs(windows) do
|
||
local id = tonumber(workspace and workspace.id or (window.workspace and window.workspace.id))
|
||
|
||
if id and id >= 1 then
|
||
if floating_workspace(id) then
|
||
table.insert(floating, window)
|
||
else
|
||
tile_for_mode(window, mine)
|
||
end
|
||
end
|
||
end
|
||
|
||
float_for_mode(floating)
|
||
end
|
||
|
||
-- Overlapping windows are placed with the pointer, so a floating workspace
|
||
-- focuses on click rather than on hover. Every other workspace keeps the user's
|
||
-- own setting.
|
||
local input = {}
|
||
|
||
local function update_input()
|
||
local workspace = hl.get_active_workspace()
|
||
local active = workspace ~= nil and floating_workspace(workspace.id)
|
||
|
||
if active == input.floating then
|
||
return
|
||
end
|
||
|
||
input.floating = active
|
||
|
||
hl.config({ input = { follow_mouse = active and 0 or input.follow_mouse } })
|
||
end
|
||
|
||
-- Windows arrive by opening and by being carried in, and a window rule only ever
|
||
-- fires for the first. One carried back out gives up the floating the mode gave
|
||
-- it rather than staying floating somewhere that tiles.
|
||
local function watch()
|
||
if watching then
|
||
return
|
||
end
|
||
|
||
watching = true
|
||
|
||
input.follow_mouse = hl.get_config("input.follow_mouse")
|
||
|
||
hl.on("window.open", function(window)
|
||
arrange({ window })
|
||
end)
|
||
|
||
-- A move is still being committed when this fires, and changing the window's
|
||
-- floating state underneath it loses the move. Let it land first.
|
||
hl.on("window.move_to_workspace", function(window, workspace)
|
||
hl.timer(function()
|
||
arrange({ window }, workspace)
|
||
end, { type = "oneshot", timeout = 1 })
|
||
end)
|
||
|
||
-- A fullscreen window is left as it is, so it is arranged once it comes back
|
||
-- out: a tiled one leaving fullscreen on a floating workspace floats then.
|
||
hl.on("window.fullscreen", function(window)
|
||
hl.timer(function()
|
||
arrange({ window })
|
||
end, { type = "oneshot", timeout = 1 })
|
||
end)
|
||
|
||
hl.on("workspace.active", update_input)
|
||
hl.on("monitor.focused", update_input)
|
||
end
|
||
|
||
function o.workspace_mode(spec)
|
||
watch()
|
||
|
||
-- The default for every workspace that has no mode of its own, chosen under
|
||
-- Trigger > Appearance > Workspace Layout. A tiled default is Hyprland's own
|
||
-- layout; a floating one is arranged once the saved modes have all loaded.
|
||
if spec.default then
|
||
default_mode = spec.default
|
||
if spec.default ~= "floating" then
|
||
hl.config({ general = { layout = spec.default } })
|
||
end
|
||
return
|
||
end
|
||
|
||
local workspace = tostring(spec.workspace)
|
||
|
||
-- Floating keeps whatever tiled layout the workspace had, so nothing has to be
|
||
-- chosen for it on the way back out.
|
||
if spec.mode ~= "floating" then
|
||
hl.workspace_rule({ workspace = workspace, layout = spec.mode })
|
||
end
|
||
|
||
modes[workspace] = spec.mode
|
||
|
||
arrange(hl.get_workspace_windows(workspace))
|
||
update_input()
|
||
end
|
||
|
||
local function theme_colors()
|
||
local colors = {}
|
||
-- omarchy-theme-set writes the current theme under ~/.local/state whatever
|
||
-- XDG_STATE_HOME says, and Hyprland loads the theme's own config from there.
|
||
local file = io.open(paths.home .. "/.local/state/omarchy/current/theme/colors.toml", "r")
|
||
|
||
if file then
|
||
for line in file:lines() do
|
||
local key, value = line:match('^%s*([%w_]+)%s*=%s*"#(%x%x%x%x%x%x)"')
|
||
if key then
|
||
colors[key] = "rgb(" .. value .. ")"
|
||
end
|
||
end
|
||
|
||
file:close()
|
||
end
|
||
|
||
local background = colors.background or "rgb(1a1b26)"
|
||
-- A titlebar in the window's own background color runs into the window, so it
|
||
-- takes the theme's raised surface. Themes that make that the background too
|
||
-- fall back to their selection color.
|
||
local titlebar = colors.lighter_background
|
||
if not titlebar or titlebar == background then
|
||
titlebar = colors.selection or background
|
||
end
|
||
|
||
return {
|
||
background = background,
|
||
foreground = colors.foreground or "rgb(c0caf5)",
|
||
titlebar = titlebar,
|
||
}
|
||
end
|
||
|
||
-- Titlebars come from omarchy-hyprland-titlebars, loaded only while something
|
||
-- floats. Hyprland keeps a plugin loaded after the config stops asking for it,
|
||
-- but resets its options, and the plugin draws nothing until enabled here.
|
||
-- The plugin reloads the config once it is in, which is the pass that finds its
|
||
-- options here.
|
||
local function titlebars()
|
||
local file = io.open(TITLEBARS, "r")
|
||
if not file then
|
||
return
|
||
end
|
||
file:close()
|
||
|
||
hl.plugin.load(TITLEBARS)
|
||
|
||
if not hl.plugin.hyprbars then
|
||
return
|
||
end
|
||
|
||
local colors = theme_colors()
|
||
local window = [[window = "address:%WINDOW%"]]
|
||
|
||
hl.config({
|
||
plugin = {
|
||
hyprbars = {
|
||
enabled = true,
|
||
workspace_tag = WORKSPACE_TAG,
|
||
bar_height = 30,
|
||
bar_color = colors.titlebar,
|
||
col = { text = colors.foreground },
|
||
bar_text_font = "monospace",
|
||
bar_text_size = 12,
|
||
bar_text_weight = "normal",
|
||
bar_text_align = "left",
|
||
bar_buttons_alignment = "right",
|
||
bar_part_of_window = true,
|
||
bar_precedence_over_border = true,
|
||
bar_padding = 10,
|
||
bar_button_padding = 8,
|
||
button_rounding = 0,
|
||
icon_on_hover = false,
|
||
inactive_button_color = colors.titlebar,
|
||
on_double_click = "hyprctl dispatch 'hl.dsp.window.fullscreen({ mode = \"maximized\", action = \"toggle\", " .. window .. " })'",
|
||
},
|
||
},
|
||
})
|
||
|
||
for _, button in ipairs({
|
||
{ icon = "×", action = "hyprctl dispatch 'hl.dsp.window.close({ " .. window .. " })'" },
|
||
{ icon = "□", action = "hyprctl dispatch 'hl.dsp.window.fullscreen({ mode = \"maximized\", action = \"toggle\", " .. window .. " })'" },
|
||
}) do
|
||
hl.plugin.hyprbars.add_button({
|
||
bg_color = colors.titlebar,
|
||
fg_color = colors.foreground,
|
||
size = 20,
|
||
icon = button.icon,
|
||
action = button.action,
|
||
})
|
||
end
|
||
end
|
||
|
||
-- Runs once every saved mode has loaded. A reload is a fresh start on this side
|
||
-- but not for the windows, so each one is brought in line with the modes as they
|
||
-- now stand: moving the default away from floating has to give back what it
|
||
-- floated, even though nothing names those workspaces any more.
|
||
function M.apply()
|
||
-- A default that no longer floats can leave no saved mode at all to start the
|
||
-- watchers, while the windows it floated still carry its tags.
|
||
local leftovers = #hl.get_windows({ tag = FLOATED_TAG }) > 0 or #hl.get_windows({ tag = WORKSPACE_TAG }) > 0
|
||
|
||
if anything_floats() or leftovers then
|
||
watch()
|
||
end
|
||
|
||
if watching then
|
||
arrange(hl.get_windows())
|
||
update_input()
|
||
end
|
||
|
||
if not anything_floats() then
|
||
return
|
||
end
|
||
|
||
-- Overlapping windows need depth to tell apart, and a translucent window over
|
||
-- another is hard to read.
|
||
if not hl.get_config("decoration.shadow.enabled") then
|
||
hl.config({ decoration = { shadow = { enabled = true, range = 16, render_power = 3, color = "rgba(00000035)" } } })
|
||
o.window(".*", { no_shadow = true })
|
||
o.window({ tag = WORKSPACE_TAG }, { no_shadow = false })
|
||
end
|
||
|
||
o.window({ tag = WORKSPACE_TAG }, { opacity = "1.0 override 1.0 override" })
|
||
|
||
titlebars()
|
||
end
|
||
|
||
return M
|