rubywm/wm.rb

521 lines
16 KiB
Ruby

class WindowManager
attr_reader :dpy, :desktops, :windows, :focus
def inspect = "<WindowManager>"
def initialize dpy, num_desktops: 10
@dpy = dpy
@windows = {}
@border_normal = 0x88666666
@border_focus = 0xffff66ff
@desktops ||= num_desktops.times.map do |num|
Desktop.new(self, num, name: (num+1).to_s[-1])
end
# FIXME: Config
(0..8).each do |i|
desktops[i].layout = TiledLayout.new(desktops[i], rootgeom)
end
# FIXME: Config
# FIXME: Improved way of specifying pre-designed layouts.
r = desktops[1].layout.root
r.ratio = 0.5
r.nodes[0] = Leaf.new(iclass: "todo-todo")
r.nodes[1] = Node.new([
Leaf.new(iclass: "todo-done"),
Leaf.new(iclass: "todo-note")],
dir: :tb
)
change_property(:_NET_NUMBER_OF_DESKTOPS, :cardinal, num_desktops)
mask = X11::Form::ButtonPressMask|X11::Form::ButtonReleaseMask|X11::Form::PointerMotionMask
root.grab_button(true, mask, :async, :async, 0, 0, 1, X11::Form::Mod3)
root.grab_button(true, mask, :async, :async, 0, 0, 3, X11::Form::Mod3)
root.grab_button(true, mask, :async, :async, 0, 0, 1, X11::Form::Mod4)
root.grab_button(true, mask, :async, :async, 0, 0, 3, X11::Form::Mod4)
eventmask = (X11::Form::SubstructureNotifyMask |
X11::Form::SubstructureRedirectMask |
X11::Form::StructureNotifyMask |
X11::Form::EnterWindowMask |
X11::Form::LeaveWindowMask |
X11::Form::ButtonPressMask |
# X11::Form::ExposureMask |
X11::Form::KeyPressMask |
X11::Form::FocusChangeMask
)
root.select_input(eventmask)
at_exit { root.set_input_focus(:parent) }
children = root.query_tree.children
children.each { |wid| window(wid) }
desktops.each(&:hide)
change_desktop(current_desktop_id)
end
def change_property(atom, type, data, mode: :replace, format: 32)
root.change_property(mode, atom, type, format, Array(data).pack("V*").unpack("C*"))
end
def current_desktop_id = (@current_desktop_id ||= root.get_property(:_NET_CURRENT_DESKTOP, :cardinal)&.value.to_i)
def current_desktop = desktops[current_desktop_id] || desktops[0]
def root_id = (@root_id ||= @dpy.screens.first.root)
def root = (@root ||= X11::Window.new(@dpy, root_id))
# FIXME: Does not take into account panels
def rootgeom = (@rootgeom ||= root.get_geometry)
def window(wid)
return root if (wid == root.wid)
return @windows[wid] if @windows[wid]
adopt(wid)
end
def update_client_list = change_property(:_NET_CLIENT_LIST, :window, @windows.keys)
# If we don't already know about this window, we "adopt" it.
def adopt(wid, desktop=nil)
return if wid.nil?
w = @windows[wid] # To avoid infinite recursion, this *must not* use #window
return w if w
w = Window.new(self, wid)
begin
STDERR.puts "\e[35madopt6\e[0m: #{wid.to_s(16)}; type=#{w.type.inspect}"
# FIXME: At least some of these ought to "adopted" but set as
# floating/non-layout so they stay on a single desktop.
#
if w.type == dpy.atom(:_NET_WM_WINDOW_TYPE_POPUP) ||
w.type == dpy.atom(:_NET_WM_WINDOW_TYPE_NOTIFICATION) ||
w.type == dpy.atom(:_NET_WM_WINDOW_TYPE_POPUP_MENU) ||
w.type == dpy.atom(:_NET_WM_WINDOW_TYPE_MENU) ||
w.type == dpy.atom(:_NET_WM_WINDOW_TYPE_DOCK) ||
w.type == dpy.atom(:_NET_WM_WINDOW_TYPE_TOOLTIP) ||
w.type == dpy.atom(:_NET_WM_WINDOW_TYPE_DIALOG) ||
w.type == dpy.atom(:_NET_WM_WINDOW_TYPE_SPLASH) ||
w.type == dpy.atom(:_NET_WM_WINDOW_TYPE_UTILITY)
w.floating = true
p [:ignoring, w.inspect]
w.stack
return w
end
if w.desktop?
w.floating = true
end
STDERR.puts "\e[35madopt5\e[0m: #{wid.to_s(16)}"
attr = w.get_window_attributes
if attr.wclass == 2 # InputOnly
# We don't want to adopt inputonly windows, as they're
# for event handling only
return w
end
return w if attr.override_redirect
w.mapped = attr.map_state != 0
geom = w.get_geometry
return w if geom.is_a?(X11::Form::Error)
return w if geom.width < 2 || geom.height < 2
@windows[wid] = w
wms = w.get_property(:_NET_WM_STATE, :atom)&.value
if wms == dpy.atom(:_NET_WM_STATE_ABOVE)
# This seems like it's probably not a good idea.
return w
end
if w.special?
w.configure(border_width: 0)
else
w.configure(border_width: 1)
w.change_attributes(values: {X11::Form::CWBorderPixel => @border_normal})
end
desktop = dpy.get_property(wid, :_NET_WM_DESKTOP, :cardinal)&.value
desktop ||= current_desktop_id
move_to_desktop(wid, desktop)
w.select_input(
X11::Form::FocusChangeMask |
X11::Form::PropertyChangeMask |
X11::Form::EnterWindowMask |
X11::Form::LeaveWindowMask
)
rescue Exception => e
p [:ZZZZZZZZZZZZZZZZZZZZZZZZZADOPT_FAILED, e]
# Failure here most likely reflects a window that has "disappeared".
# We should handle that better, but for now this is fine
end
update_client_list
return w
end
def map_window(wid)
w = window(wid)
attr = w.get_geometry
return if attr.is_a?(X11::Form::Error)
x = attr.x
y = attr.y
width = attr.width
height = attr.height
width = rootgeom.width / 2 if width < 10
height = rootgeom.height - 100 if height < 10
if w.floating?
x = (rootgeom.width - width) /2 if x == 0
y = (rootgeom.height - height)/2 if y == 0
w.configure(x:, y:, width:, height:)
else
# We're cheekily claiming it's already been mapped
# so it's included in the layout
current_desktop.layout&.place_adjacent(w, @focus) if @focus
end
w.map
set_focus(wid) unless w.special?
end
def move_to_desktop(wid,desktop)
return if wid == root_id
d = desktops[desktop] || desktops[0]
w = window(wid)
old = w.desktop
w.desktop = d
d.update_layout if d.active?
old&.update_layout
d.active? ? w.show : w.hide
end
def change_desktop(d)
if current_desktop_id == d
# Allow using this as refresh, but reduce disruption
current_desktop.update_layout
return current_desktop.show
end
old = current_desktop
@current_desktop_id = d
current_desktop.show
current_desktop.update_layout
# FIXME: Switch focus (keep focus stack per desktop)
old.hide
change_property(:_NET_CURRENT_DESKTOP, :cardinal, d)
f = current_desktop&.mapped_regular_children&.first
set_focus(f.wid) if f
end
def set_focus(wid)
return if wid == root_id
w = window(wid)
p [:set_focus, wid, w]
# FIXME: This may be a bit brutal, in that it prevents keyboard control of the desktop or dock.
return if w.special?
@focus&.change_attributes(values: {X11::Form::CWBorderPixel => @border_normal})
@focus = w
@focus.set_input_focus(:parent)
@focus.change_attributes(values: {X11::Form::CWBorderPixel => @border_focus})
p [:set_focus, wid, @focus, :children]
change_property(:_NET_ACTIVE_WINDOW, :window, wid)
end
# FIXME: This needs tweaks. Especially for floating windows, where
# what we really want is to e.g. treat partially overlapping windows
# so that the one closest to *overlapping* the correct border is picked
def find_closest(w, dir, from)
g = w.get_geometry
case dir
when :left then predicate = ->(g2) { g.x - (g2.x + g2.width) }
when :right then predicate = ->(g2) { g2.x - (g.x + g.width) }
when :up then predicate = ->(g2) { g.y - (g2.y + g2.height) }
when :down then predicate = ->(g2) { g2.y - (g.y + g.height) }
end
min = 10000
list = []
p [:here]
from.each do |win|
p [:checking, win, dir]
g2 = win.get_geometry rescue nil
next if g2.nil?
dist = predicate.call(g2).abs
if dist <= min
if dist == min
list << win
else
list = [win]
min = dist
end
end
p [dist, min, list]
end
p [min, list]
return nil if list.empty?
return list.first if list.length == 1
# More than one in the same direction,
# FIXME: For now we just pick the first.
# Ideally I'd probably want to request the pointer location
# and find the closest along the other axis.
# May also want to check which window had focus last,
# and track last direction, so that e.g. left->right->left
# will go back to the same window
return list.first
end
def destroy_window(wid)
if w = @windows[wid]
@windows.delete(wid)
current_desktop.update_layout
update_client_list
end
end
# # X Event-handlers
def on_error(ev) = destroy_window(ev.bad_resource_id)
# FIXME: Shouldn't most of these be dispatched to the *window*?
def on_map_notify(ev)
w = window(ev.window)
w.mapped = true
current_desktop.update_layout
end
def on_unmap_notify(ev)
window(ev.window).mapped = false
current_desktop.update_layout
end
def on_map_request(ev) = map_window(ev.window)
def on_property_notify(ev)
p dpy.get_atom_name(ev.atom) rescue nil
end
def on_button_press(ev)
return if !ev.child
w = window(ev.child)
@attr = w.get_geometry rescue nil
if @attr
set_focus(w.wid)
@start = ev
end
end
def on_motion_notify(ev)
# @start.button == 1 -> move
# @start.button == 3 -> resize
if ev.child != @start.child
set_focus(ev.child) rescue nil # FIXME
end
return if !@start&.child || !@attr
xdiff = ev.root_x - @start.root_x;
ydiff = ev.root_y - @start.root_y;
w = window(@start.child)
# FIXME: Any other types we don't want to allow moving or resizing
begin
return if w.special?
rescue # FIXME: Why is this here?
end
#p w
if @start.detail == 1 # Move
if w.floating?
w.configure(x: @attr.x + xdiff, y: @attr.y + ydiff)
end
elsif @start.detail == 3 # Resize
lr = (ev.event_x-@attr.x < @attr.width / 2)
tb = (ev.event_y-@attr.y < @attr.height/ 2)
if w.floating?
# If left/above the centre point, we grow/shrink the window to the left/top
# otherwise to the right/bottom. Doing it to the left/top requires
# moving it at the same time.
@attr.x = @attr.x + (lr ? xdiff : 0)
@attr.y = @attr.y + (tb ? ydiff : 0)
@attr.width = @attr.width + (lr ? -xdiff : xdiff)
@attr.height = @attr.height+ (tb ? -ydiff : ydiff)
w.configure(x: @attr.x, y: @attr.y, width: @attr.width, height: @attr.height)
else
layout = current_desktop&.layout
if layout
prev = layout.find(w)
node = prev.parent
while node #&& node != layout.root
# FIXME: This duplication infuriates me, but it's inherent
# in having meaningful names for left/right/top/bottom/x/y
# It'd go away if using array indices, but is it worth it?
if !lr.nil? && node.dir == :lr
dx = node.geom ? (((node.geom.width * node.ratio) + xdiff)/node.geom.width) - node.ratio : 0.0
if node.nodes[0] == prev && !lr
node.ratio += dx
lr = nil
elsif node.nodes[1] == prev
node.ratio += dx
lr = nil
end
end
if !tb.nil? && node.dir == :tb
dy = node.geom ? (((node.geom.height * node.ratio) + ydiff)/node.geom.height) - node.ratio : 0.0
if node.nodes[0] == prev && !tb
node.ratio += dy
tb = nil
elsif node.nodes[1] == prev
node.ratio += dy
tb = nil
end
end
node.ratio = node.ratio.clamp(0.1,0.9)
prev = node
node = node.parent
end
p :RESIZE_DONE
layout.call
end
end
@start.root_x = ev.root_x
@start.root_y = ev.root_y
end
end
def on_button_release(ev)
@start.child = nil if @start
end
def on_focus_in(ev) = focus || set_focus(ev.event)
def on_enter_notify(ev) = set_focus(ev.event)
def on_unmap_notify(ev) = window(ev.window)&.desktop&.update_layout
def on_destroy_notify(ev) = destroy_window(ev.window)
# # Client Messages
def on_net_active_window(wid, ...) = map_window(wid)
def on_net_restack_window(wid,source, sibling_wid, detail)
w = window(wid)
# FIXME: Handle sibling.
detail = case detail
when 0 then :above
when 1 then :below
else detail
end
w.configure(stack_mode: detail)
end
def on_net_current_desktop(_, d) = change_desktop(d)
def on_net_wm_state(wid, action, prop1, prop2, source)
w = window(wid)
p [:got_wm_state_for, w, prop1 == 0 ? "None" : dpy.get_atom_name(prop1),
prop2 == 0 ? "None" : dpy.get_atom_name(prop2)]
# FIXME: Need to check if "action" for toggle vs set/clear
[prop1, prop2].each do |prop|
case prop
when dpy.atom(:_NET_WM_STATE_FULLSCREEN)
w.toggle_maximize
end
end
# For the time being, we recognize two things only:
# NET_WM_STATE_FULLSCREEN and NET_WM_STATE_MAXIMIZED_{VERT,HORZ}
end
# FIXME: This should be _NET_CLOSE_WINDOW
# and _NET_CLOSE_WINDOW should initiate a WM_DELETE_WINDOW
# *to the client* if they support it, w/fallback to destroyf
def on_net_wm_desktop(wid, d) = move_to_desktop(wid, d)
def on_wm_delete_window(*args)
# FIXME: Include id in args
@focus.destroy if @focus
end
# # RWM specific ClientMessages
# Move focus to the "nearest" window in `dir` direction
def on_rwm_focus(_, dir)
dir = dpy.get_atom_name(dir).downcase.to_sym
return if !@focus || @focus.special?
w = find_closest(@focus, dir, @focus.desktop.mapped_regular_children)
set_focus(w.wid) if w
end
# Toggle the direction of the node split.
def on_rwm_shift_direction(_,dir)
# FIXME: Respect the window passed instead of doing it to @focus
return if !@focus || @focus.special?
node = current_desktop&.layout&.find(@focus)
if node
node = node.parent if node.is_a?(Leaf)
node.dir = node.dir == :lr ? :tb : :lr
current_desktop&.update_layout
end
end
# Swap nodes in the nearest parent node of the focused window
def on_rwm_swap_nodes(_)
# FIXME: Respect the window passed instead of doing it to @focus
# no matter what
return if !@focus || @focus.special?
node = current_desktop&.layout&.find(@focus)
if node
node = node.parent if node.is_a?(Leaf)
tmp = node.nodes[0]
node.nodes[0] = node.nodes[1]
node.nodes[1] = tmp
current_desktop&.update_layout
end
end
# Move the focused window, either swapping it into the container
# of the nearest leaf (if tiled), or moving it stepwise if floating
# FIXME: Just have rwm move specify x/y *offsets* instead? Would
# save an (admittedly cached) get_atom_name
def on_rwm_move(_,dir)
return if !@focus || @focus.special?
dir = dpy.get_atom_name(dir).downcase.to_sym
if @focus.floating?
# FIXME:
# Move stepwise instead.
g = @focus.get_geometry rescue nil
return if g.nil?
case dir
when :left then @focus.configure(x: g.x - 20)
when :right then @focus.configure(x: g.x + 20)
when :down then @focus.configure(y: g.y + 20)
when :up then @focus.configure(y: g.y - 20)
end
return
end
w = find_closest(@focus, dir, @focus.desktop.mapped_regular_children)
l1 = @focus.desktop&.layout&.find(@focus)
l2 = w&.desktop&.layout&.find(w)
if l1 && l2
l2.window = @focus
l1.window = w
@focus.desktop.update_layout
# FIXME: We want to ensure focus stays in @focus
# here. Not sure how. We get enter/leave/focus in/out
# events. How can we get button/motion events for individual windows
# Maybe I have to do
# https://stackoverflow.com/questions/62448181/how-do-i-monitor-mouse-movement-events-in-all-windows-not-just-one-on-x11
# XInput v2.0
# And then ignore enter/leave events. Seems stupid
# Investigate what Katriawm does?
set_focus(@focus.wid)
end
end
end