-
-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathwm.rb
More file actions
1857 lines (1634 loc) · 63.1 KB
/
Copy pathwm.rb
File metadata and controls
1857 lines (1634 loc) · 63.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
require_relative 'floating'
require_relative 'monitor'
require_relative 'desktop_navigation'
require 'json'
class WindowManager
attr_reader :dpy, :desktops, :windows, :focus, :monitors
class OwnershipError < StandardError; end
UINT32_MAX = 0xffffffff
DESKTOP_PROTOCOL_VERSION = 1
REMOVE_FORCE = 1 << 0
ROOT_EVENT_MASK = (
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
).freeze
def inspect = "<WindowManager>"
def initialize dpy, config
@dpy = dpy
@windows = {}
@monitors = []
@startup_packets = []
claim_window_manager!
snapshot = read_desktop_snapshot
@desktop_generation = snapshot&.dig(:generation).to_i
@border_normal = 0x88666666
@border_focus = 0xffff66ff
process_config(config, snapshot)
mask = X11::Form::ButtonPressMask|X11::Form::ButtonReleaseMask|X11::Form::PointerMotionMask
# Grab Super+button1/3 (move/resize), ignoring the lock modifiers
# (NumLock=Mod2, CapsLock=Lock) by grabbing every combination of them.
lock_combos = [0, X11::Form::Mod2, X11::Form::Lock, X11::Form::Mod2|X11::Form::Lock]
[1, 3].each do |button|
lock_combos.each do |lock|
root.grab_button(true, mask, :async, :async, 0, 0, button, X11::Form::Mod4|lock)
end
end
at_exit { root.set_input_focus(:parent) }
children = root.query_tree.children
children.each { |wid| window(wid) }
desktops.each(&:hide)
# Then show only the active desktop for each monitor
@monitors.each do |monitor|
if desktop = monitor.active_desktop
desktop.show
end
end
# Set current desktop ID property based on active monitor
if active_monitor&.active_desktop
set_current_desktop(active_monitor.active_desktop.id)
else
# Fallback to desktop 0 if no active desktop
change_desktop(0)
end
setup_ewmh
@desktop_state_ready = true
publish_desktop_state
end
# SubstructureRedirectMask may only be selected by one X client. pure-x11
# queues errors from void requests asynchronously, so follow the selection
# with a synchronous request, then inspect the packets that preceded its
# reply. Without this barrier a second RubyWM can appear to start, overwrite
# EWMH state, and still leave the old process handling all WM commands.
def claim_window_manager!
root.select_input(ROOT_EVENT_MASK)
@dpy.get_input_focus
queued = []
queued << @dpy.next_packet while @dpy.peek_packet
ownership_error = queued.find do |packet|
packet.is_a?(X11::Form::Error) &&
packet.code == 10 && # BadAccess
packet.major_opcode == 2 && # ChangeWindowAttributes
packet.bad_resource_id == root_id
end
if ownership_error
raise OwnershipError,
"another window manager already owns display #{ENV.fetch("DISPLAY", ":0")}"
end
protocol_error = queued.find { _1.is_a?(X11::Form::Error) }
raise X11::Error.new(protocol_error) if protocol_error
@startup_packets.concat(queued)
end
def next_packet
@startup_packets.empty? ? @dpy.next_packet : @startup_packets.shift
end
# Publish which desktop hosts desktop-type windows (config `desktop: true`),
# as a custom _RWM_DESKTOP_WINDOW_DESKTOP cardinal on the root, so the desktop
# client (Dwin) reads it instead of hardcoding a desktop number.
def publish_desktop_window_host
return unless @desktop_window_desktop
change_property(:_RWM_DESKTOP_WINDOW_DESKTOP, :cardinal,
@desktop_window_desktop.id)
end
def publish_utf8_list(atom, values)
bytes = (values.map { _1.to_s }.join("\0") + "\0").bytes
root.change_property(:replace, atom, dpy.atom(:UTF8_STRING), 8, bytes)
rescue X11::Error => e
$logger.debug { "error publishing #{atom}: #{e.message}" }
end
# Publish the standard EWMH view and the aligned RubyWM metadata arrays as one
# logical snapshot. _RWM_DESKTOP_GENERATION is written last so readers can
# detect an update that crossed multiple X properties.
def publish_desktop_state
change_property(:_NET_NUMBER_OF_DESKTOPS, :cardinal, @desktops.length)
publish_utf8_list(:_NET_DESKTOP_NAMES, @desktops.map(&:name))
current = @desktops[@current_desktop_id] ||
@primary_monitor&.active_desktop || @desktops.first
@current_desktop_id = current&.id || 0
change_property(:_NET_CURRENT_DESKTOP, :cardinal, @current_desktop_id)
@monitors.each do |monitor|
next unless monitor.active_desktop
change_property("_NET_CURRENT_DESKTOP_MONITOR_#{monitor.id}".to_sym,
:cardinal, monitor.active_desktop.id)
end
geom = rootgeom
change_property(:_NET_DESKTOP_GEOMETRY, :cardinal,
[geom.width, geom.height])
change_property(:_NET_DESKTOP_VIEWPORT, :cardinal,
[0, 0] * @desktops.length)
publish_workarea
publish_utf8_list(:_RWM_DESKTOP_KEYS, @desktops.map(&:key))
publish_utf8_list(:_RWM_DESKTOP_PROJECTS,
@desktops.map { _1.project || "" })
publish_utf8_list(:_RWM_DESKTOP_GROUPS,
@desktops.map { _1.group || "" })
change_property(:_RWM_DESKTOP_MODES, :cardinal,
@desktops.map(&:mode_code))
change_property(:_RWM_DESKTOP_FLAGS, :cardinal,
@desktops.map(&:flags))
change_property(:_RWM_DESKTOP_CLIENT_COUNTS, :cardinal,
desktop_client_counts)
publish_desktop_window_host
publish_visible_desktops
publish_desktop_history
publish_desktop_generation
end
def desktop_client_counts
@desktops.map { |desktop| desktop.children.length }
end
# Occupancy changes independently of desktop metadata when clients are
# opened, closed, or moved. Publish the aligned counts first and generation
# last so Rebar can update without polling every client window.
def publish_desktop_occupancy
return unless @desktop_state_ready
change_property(:_RWM_DESKTOP_CLIENT_COUNTS, :cardinal,
desktop_client_counts)
publish_desktop_generation
end
def publish_visible_desktops
change_property(:_RWM_VISIBLE_DESKTOPS, :cardinal,
@monitors.map { _1.active_desktop&.id || UINT32_MAX })
end
def publish_desktop_generation
@desktop_generation = (@desktop_generation.to_i + 1) & UINT32_MAX
change_property(:_RWM_DESKTOP_GENERATION, :cardinal,
@desktop_generation)
end
# EWMH hints we actually honour. Advertised via _NET_SUPPORTED so toolkits
# (and pagers/wmctrl) know an EWMH-compliant WM is running.
SUPPORTED_HINTS = %i[
_NET_SUPPORTING_WM_CHECK _NET_SUPPORTED
_NET_NUMBER_OF_DESKTOPS _NET_CURRENT_DESKTOP
_NET_ACTIVE_WINDOW _NET_CLIENT_LIST _NET_CLOSE_WINDOW
_NET_WM_DESKTOP _NET_WM_STATE _NET_WM_STATE_FULLSCREEN
_NET_WM_STATE_MAXIMIZED_VERT _NET_WM_STATE_MAXIMIZED_HORZ
_NET_WM_WINDOW_TYPE _NET_WM_WINDOW_TYPE_DESKTOP
_NET_WM_WINDOW_TYPE_DOCK _NET_WM_WINDOW_TYPE_DIALOG
_NET_WM_STRUT _NET_WM_STRUT_PARTIAL _NET_WORKAREA
_NET_CLIENT_LIST_STACKING _NET_DESKTOP_NAMES
_NET_DESKTOP_GEOMETRY _NET_DESKTOP_VIEWPORT
].freeze
# Advertise EWMH compliance: a persistent child window referenced by
# _NET_SUPPORTING_WM_CHECK on both the root and itself, named via
# _NET_WM_NAME, plus the _NET_SUPPORTED hint list on the root.
def setup_ewmh
@check_window = @dpy.create_window(-1, -1, 1, 1,
depth: rootgeom.depth,
values: { X11::Form::CWOverrideRedirect => 1 })
win_bytes = [@check_window].pack("V*").unpack("C*")
@dpy.change_property(:replace, root.wid, :_NET_SUPPORTING_WM_CHECK, :window, 32, win_bytes)
@dpy.change_property(:replace, @check_window, :_NET_SUPPORTING_WM_CHECK, :window, 32, win_bytes)
@dpy.change_property(:replace, @check_window, :_NET_WM_NAME, dpy.atom(:UTF8_STRING), 8, "rubywm".bytes)
change_property(:_NET_SUPPORTED, :atom, SUPPORTED_HINTS.map { dpy.atom(_1) })
rescue => e
$logger.error("setup_ewmh failed: #{e.class}: #{e.message}")
end
def set_current_desktop(desktop, monitor = active_monitor)
@current_desktop_id = desktop
change_property(:_NET_CURRENT_DESKTOP, :cardinal, desktop)
if monitor
change_property("_NET_CURRENT_DESKTOP_MONITOR_#{monitor.id}".to_sym,
:cardinal, desktop)
end
publish_visible_desktops
publish_desktop_history
publish_desktop_generation
end
def process_node_child(spec, n)
if spec[:type] != :node && !spec[:nodes]
return Leaf.new(iclass: spec[:iclass], parent: n)
end
cur = Node.new(parent: n)
process_node_config(cur, spec, n)
return cur
end
def process_node_config(n, spec, parent=nil)
n.ratio = spec[:ratio] if spec&.dig(:ratio)
n.dir = spec[:dir].to_sym if spec&.dig(:dir)
Array(spec&.dig(:nodes)).each do |sub|
n.nodes << process_node_child(sub, n)
end
end
def process_monitors
monitors = @dpy.xinerama_query_screens.screens
if monitors.empty?
# Create a default monitor using root geometry if none defined
geom = rootgeom
@primary_monitor = WM::Monitor.new(0,
width: geom.width,
height: geom.height,
xoffset: 0,
yoffset: 0)
@monitors = [@primary_monitor]
else
monitors.each_with_index do |screen, id|
monitor = WM::Monitor.new(id,
width: screen.width,
height: screen.height,
xoffset: screen.x_org,
yoffset: screen.y_org)
@monitors[id] = monitor
# First monitor in config becomes primary by default
@primary_monitor ||= monitor
end
end
$logger.info("Monitors: #{@monitors.inspect}")
end
# Find the monitor that contains a given point
def monitor_for_point(x, y)
@monitors.find do |monitor|
x >= monitor.xoffset && x < (monitor.xoffset + monitor.width) &&
y >= monitor.yoffset && y < (monitor.yoffset + monitor.height)
end
end
# Get the current pointer position
def pointer_position
query = @dpy.query_pointer(root.wid)
return query.root_x, query.root_y
rescue X11::Error => e
$logger.debug { "error getting pointer position: #{e.message}" }
return nil, nil
end
# Get the monitor containing the mouse pointer, falling back to the focused
# window's monitor and then the primary monitor.
def active_monitor
x, y = pointer_position
if x.nil? || y.nil?
return @focus ? monitor_for_window(@focus) : @primary_monitor
end
monitor_for_point(x, y)
end
# Find the monitor that contains a window (using center point)
def monitor_for_window(window)
geom = window.get_geometry
return @primary_monitor if !geom || geom.is_a?(X11::Form::Error)
# Get center point of window
center_x = geom.x + (geom.width / 2)
center_y = geom.y + (geom.height / 2)
# Find monitor containing this point
monitor = monitor_for_point(center_x, center_y)
# Always return a monitor - use primary if no matching monitor found
monitor || @primary_monitor
rescue X11::Error => e
$logger.debug { "error finding monitor for window #{window.wid}: #{e.message}" }
@primary_monitor
end
# FIXME: I'm not particlarly happy about building this in.
# I prefer the bspwm approach of externalising it, because
# I need/want an API to change it dynamically anyway, so
# this is likely to change.
def root_cardinals(atom, length: 1024)
value = @dpy.get_property(root_id, atom, :cardinal,
length: length)&.value
value.nil? ? [] : Array(value)
rescue X11::Error
[]
end
def root_utf8_list(atom)
value = @dpy.get_property(root_id, atom, dpy.atom(:UTF8_STRING),
length: 16_384)&.value
return [] if value.nil?
values = value.to_s.split("\0", -1)
values.pop if values.last == ""
values
rescue X11::Error
[]
end
# A RubyWM-owned root snapshot survives a WM restart because X properties
# live on the server. Existing clients keep their numeric EWMH assignments,
# while stable keys and metadata are reconstructed before they are adopted.
def read_desktop_snapshot
generation = root_cardinals(:_RWM_DESKTOP_GENERATION, length: 1).first
count = root_cardinals(:_NET_NUMBER_OF_DESKTOPS, length: 1).first.to_i
return if generation.nil? || count < 1 || count > 256
keys = root_utf8_list(:_RWM_DESKTOP_KEYS)
names = root_utf8_list(:_NET_DESKTOP_NAMES)
projects = root_utf8_list(:_RWM_DESKTOP_PROJECTS)
groups = root_utf8_list(:_RWM_DESKTOP_GROUPS)
modes = root_cardinals(:_RWM_DESKTOP_MODES)
flags = root_cardinals(:_RWM_DESKTOP_FLAGS)
return unless [keys, names, projects, groups, modes, flags].all? {
_1.length == count
}
return if keys.any?(&:empty?) || keys.uniq.length != count
records = count.times.map do |id|
{
key: keys[id],
name: names[id],
project: projects[id].empty? ? nil : projects[id],
group: groups[id].empty? ? nil : groups[id],
mode: Desktop::MODE_CODES.key(modes[id]) || :tiled,
flags: flags[id].to_i,
}
end
{
generation: generation,
records: records,
visible: root_cardinals(:_RWM_VISIBLE_DESKTOPS,
length: 256),
history: root_cardinals(:_RWM_DESKTOP_HISTORY,
length: 65_536),
}
rescue X11::Error => e
$logger.debug { "error reading desktop snapshot: #{e.message}" }
nil
end
def configured_desktop_records(config)
count = config.dig(:desktops, :number) || 10
count.times.map do |num|
desktop_config = config.fetch(:desktops, {})
configured = desktop_config.key?(num + 1)
c = desktop_config[num + 1] || {}
flags = if configured
Desktop::FLAG_STATIC
else
Desktop::FLAG_SLOT_BACKED | Desktop::FLAG_AVAILABLE
end
flags |= Desktop::FLAG_PINNED if c[:pinned]
flags |= Desktop::FLAG_GROUP_ROOT if c[:group_root]
if c[:desktop]
flags |= Desktop::FLAG_DESKTOP_WINDOW | Desktop::FLAG_PINNED
end
{
key: (c[:key] || "desktop-#{num + 1}").to_s,
name: (c[:name] || (num + 1).to_s).to_s,
project: c[:project]&.to_s,
group: c[:group]&.to_s,
mode: c[:layout] == "floating" ? :floating : :tiled,
flags: flags,
config: c,
}
end
end
def merge_desktop_records(configured, snapshot)
return configured unless snapshot
snapshot_records = snapshot[:records].map(&:dup)
records = configured.each_with_index.map do |configured_record, index|
prior = snapshot_records[index]
if configured_record[:flags] & Desktop::FLAG_STATIC != 0
# Explicit numbered configuration owns its numeric slot. Configuration
# remains authoritative for its identity, mode, flags, and layout.
configured_record
elsif prior &&
prior[:flags] & Desktop::FLAG_SLOT_BACKED != 0
# A placeholder or project lease remains at the same keyboard address.
prior.merge(config: configured_record[:config])
else
# Upgrade snapshots from before slot flags existed to the new implicit
# placeholder model instead of treating all ten initial slots as static.
configured_record
end
end
# Snapshot records beyond the configured compatibility pool are genuine
# appended dynamic desktops. Keep them in their existing EWMH order.
records.concat(snapshot_records.drop(configured.length))
records
end
# Before the group-aware allocator existed, every project leased an initial
# numeric slot. That made secondary projects steal independent Super+n
# addresses. Keep one numeric address per group and move every additional
# slot-backed child to the overflow tail, restoring its numeric placeholder.
def normalize_group_child_leases(records, configured)
normalized = records.map(&:dup)
by_group = Hash.new { |hash, key| hash[key] = [] }
normalized.each_with_index do |record, index|
group = record[:group].to_s.strip
next if group.empty? || index >= configured.length
next if record[:flags].to_i & Desktop::FLAG_AVAILABLE != 0
by_group[group] << [index, record]
end
move_indices = by_group.values.flat_map do |entries|
root = entries.find {
_2[:flags].to_i & Desktop::FLAG_GROUP_ROOT != 0
}
configured_entry = entries.find {
_2[:flags].to_i & Desktop::FLAG_STATIC != 0
}
keep_index = (root || configured_entry || entries.first)&.first
entries.filter_map do |index, record|
next if index == keep_index
index if record[:flags].to_i & Desktop::FLAG_SLOT_BACKED != 0
end
end.sort
remap = {}
moved = move_indices.map do |index|
record = normalized[index].dup
record[:flags] &= ~(Desktop::FLAG_SLOT_BACKED |
Desktop::FLAG_AVAILABLE)
record[:config] = nil
remap[index] = normalized.length + remap.length
normalized[index] = configured[index].dup
record
end
[normalized.concat(moved), remap]
end
def process_config(config, snapshot = nil)
process_monitors
# FIXME: Move to each monitor.
@floating = FloatingLayout.new(self)
configured = configured_desktop_records(config)
@initial_desktop_count = configured.length
records = merge_desktop_records(configured, snapshot)
records, @desktop_index_remap =
normalize_group_child_leases(records, configured)
keys = records.map { _1[:key] }
raise ArgumentError, "desktop keys must be unique" if keys.uniq != keys
@desktops = records.each_with_index.map do |record, id|
Desktop.new(self, id, record[:name],
key: record[:key],
project: record[:project],
group: record[:group],
mode: record[:mode],
flags: record[:flags])
end
visible = snapshot&.dig(:visible) || []
used = []
@monitors.each_with_index do |monitor, monitor_id|
requested = visible[monitor_id]
requested = @desktop_index_remap.fetch(requested, requested) if requested
desktop = @desktops[requested] if requested && requested != UINT32_MAX
desktop = nil if desktop && used.include?(desktop)
desktop ||= @desktops.find { !used.include?(_1) }
monitor.active_desktop = desktop
used << desktop if desktop
end
restore_desktop_history(snapshot)
@desktops.each_with_index do |desktop, id|
record = records[id]
@desktop_window_desktop = desktop if desktop.desktop_window?
next if desktop.mode == :floating
desktop.layout = TiledLayout.new(desktop, desktop.geometry)
process_node_config(desktop.layout.root, record[:config])
end
end
# History is held as Desktop objects, so explicit renumbering preserves it.
# On the root it is a fixed-width MRU row per monitor, padded with UINT32_MAX.
def restore_desktop_history(snapshot)
old_count = snapshot&.dig(:records)&.length.to_i
raw = snapshot&.dig(:history) || []
@desktop_history = @monitors.each_with_index.map do |monitor, monitor_id|
restored = if old_count.positive?
raw.slice(monitor_id * old_count, old_count).to_a.filter_map do |id|
next if id == UINT32_MAX
@desktops[(@desktop_index_remap || {}).fetch(id, id)]
end
else
[]
end
restored = restored.select { @desktops.include?(_1) }.uniq
active = monitor.active_desktop
active ? [active, *restored.reject { _1 == active }] : restored
end
end
def remember_desktop(monitor, desktop)
return unless monitor && desktop
history = (@desktop_history[monitor.id] ||= [])
history.delete(desktop)
history.unshift(desktop)
end
def forget_desktop(desktop)
@desktop_history&.each { _1.delete(desktop) }
end
def desktop_history_indices
count = @desktops.length
@monitors.flat_map do |monitor|
history = Array(@desktop_history[monitor.id])
.select { @desktops.include?(_1) }
.uniq
.first(count)
.map(&:id)
history + Array.new(count - history.length, UINT32_MAX)
end
end
def publish_desktop_history
change_property(:_RWM_DESKTOP_HISTORY, :cardinal,
desktop_history_indices)
end
def desktop_navigation_for(monitor)
rows = @desktops.map do |desktop|
{
index: desktop.id,
key: desktop.key,
name: desktop.name,
project: desktop.project,
group: desktop.group,
mode: desktop.mode,
flags: desktop.flags,
occupied: !desktop.children.empty?,
}
end
history = Array(@desktop_history[monitor.id]).map(&:id)
DesktopNavigation.new(
rows,
current: monitor.active_desktop&.id,
history: history,
)
end
def desktop_for_group_address(address, monitor)
desktop_navigation_for(monitor).group_target(address)
end
def append_desktop(key:, name:, project: nil, group: nil, mode: :tiled,
flags: 0, publish: true)
raise ArgumentError, "desktop key is already in use" if @desktops.any? {
_1.key == key
}
desktop = Desktop.new(self, @desktops.length, name,
key: key, project: project, group: group,
mode: mode, flags: flags)
if mode == :tiled
desktop.layout = TiledLayout.new(desktop, desktop.geometry)
end
@desktops << desktop
publish_desktop_state if publish
desktop
end
# Rebar's top-bar "+" needs no naming policy or filesystem access. It asks
# RubyWM for one temporary empty view in the current group; the user can then
# re-key and describe that active view through `rwmctl edit current`.
def add_group_view(monitor)
source = monitor&.active_desktop
raise ArgumentError, "monitor does not exist" unless source
group = source.group.to_s.strip
raise ArgumentError, "current desktop has no group" if group.empty?
desktop = append_desktop(
key: next_automatic_desktop_key,
name: "New view",
project: source.project,
group: group,
mode: :tiled,
)
change_desktop_on_monitor(monitor, desktop)
desktop
end
def reusable_desktop(excluding: nil)
@desktops.find do |desktop|
next if desktop == excluding
desktop.available_placeholder? &&
!desktop.static? &&
!desktop.pinned? &&
!desktop.desktop_window? &&
!desktop.active? &&
desktop.children.empty?
end
end
def reset_available_desktop(desktop, id)
forget_desktop(desktop)
desktop.id = id
desktop.key = "desktop-#{id + 1}"
desktop.name = (id + 1).to_s
desktop.project = nil
desktop.group = nil
desktop.mode = :tiled
desktop.flags = Desktop::FLAG_SLOT_BACKED | Desktop::FLAG_AVAILABLE
desktop.layout = TiledLayout.new(desktop, desktop.geometry)
desktop
end
def trim_trailing_available_desktops
minimum = @initial_desktop_count || 1
while @desktops.length > minimum
desktop = @desktops.last
break unless desktop.available_placeholder? &&
!desktop.active? &&
desktop.children.empty?
forget_desktop(desktop)
@desktops.pop
end
end
# Move a dynamic desktop into an existing reusable slot without recreating
# its windows or layout. Desktop and monitor references point to the Desktop
# object, so changing that object's EWMH id moves the complete view atomically.
def move_desktop(source_index, target_index: nil, publish: true)
source = @desktops[source_index]
return { ok: false, error: "desktop does not exist" } unless source
if source.static? || source.pinned? || source.desktop_window?
return { ok: false, error: "configured or pinned desktops cannot be renumbered" }
end
if source.available_placeholder?
return { ok: false, error: "source desktop is an empty placeholder" }
end
target = target_index.nil? ? reusable_desktop(excluding: source) :
@desktops[target_index]
return { ok: false, error: "no reusable desktop slot is available" } unless target
unless target.available_placeholder? &&
!target.static? &&
!target.pinned? &&
!target.desktop_window? &&
!target.active? &&
target.children.empty?
return { ok: false, error: "target desktop is not an empty reusable slot" }
end
return { ok: false, error: "source and target are the same desktop" } if
source == target
old_index = source.id
new_index = target.id
if new_index < (@initial_desktop_count || 0) &&
group_has_numeric_address?(source.group, excluding: source)
return {
ok: false,
error: "group already has a numeric desktop address",
}
end
@desktops[new_index] = source
@desktops[old_index] = target
source.id = new_index
source.flags &= ~(Desktop::FLAG_STATIC | Desktop::FLAG_AVAILABLE)
source.flags |= Desktop::FLAG_SLOT_BACKED
reset_available_desktop(target, old_index)
trim_trailing_available_desktops
@desktops.each_with_index { |desktop, id| desktop.id = id }
@windows.each_value(&:publish_desktop)
monitor = source.monitor
point_layout_at(source, monitor) if monitor
current = active_monitor&.active_desktop || @primary_monitor&.active_desktop
@current_desktop_id = current&.id || 0
publish_desktop_state if publish
{
ok: true,
key: source.key,
from: old_index,
desktop: source.id,
number: source.id + 1,
count: @desktops.length,
}
end
def lease_desktop(desktop, key:, name:, project:, group:, mode:, flags:)
desktop.key = key
desktop.name = name
desktop.project = project
desktop.group = group
desktop.mode = mode
desktop.flags = Desktop::FLAG_SLOT_BACKED | flags
desktop.layout = if mode == :tiled
TiledLayout.new(desktop, desktop.geometry)
end
desktop
end
def group_already_addressed?(group, excluding: nil)
group = group.to_s.strip
return false if group.empty?
@desktops.any? do |desktop|
desktop != excluding &&
!desktop.available_placeholder? &&
desktop.group.to_s.strip == group
end
end
def group_has_numeric_address?(group, excluding: nil)
group = group.to_s.strip
return false if group.empty?
@desktops.first(@initial_desktop_count).any? do |desktop|
desktop != excluding &&
!desktop.available_placeholder? &&
desktop.group.to_s.strip == group
end
end
def secondary_group_slot?(desktop)
return false unless desktop.slot_backed?
return false unless desktop.id < (@initial_desktop_count || 0)
group = desktop.group.to_s.strip
return false if group.empty?
peers = @desktops.first(@initial_desktop_count).select do |candidate|
!candidate.available_placeholder? &&
candidate.group.to_s.strip == group
end
keep = peers.find(&:group_root?) ||
peers.find(&:static?) ||
peers.min_by(&:id)
keep && keep != desktop
end
# Move a secondary project out of the fixed numeric pool while keeping the
# Desktop object itself. Windows, monitor ownership, layout, and MRU history
# therefore follow it; the vacated number becomes an ordinary empty desktop.
def rehome_group_child_to_tail(desktop, publish: true)
raise ArgumentError, "desktop limit reached" if @desktops.length >= 256
old_index = desktop.id
base_key = "desktop-#{old_index + 1}"
desktop.key = next_automatic_desktop_key if desktop.key == base_key
placeholder = Desktop.new(
self,
old_index,
(old_index + 1).to_s,
key: base_key,
mode: :tiled,
flags: Desktop::FLAG_SLOT_BACKED | Desktop::FLAG_AVAILABLE,
)
placeholder.layout = TiledLayout.new(placeholder, placeholder.geometry)
@desktops[old_index] = placeholder
desktop.id = @desktops.length
desktop.flags &= ~(Desktop::FLAG_SLOT_BACKED | Desktop::FLAG_AVAILABLE)
@desktops << desktop
@windows.each_value(&:publish_desktop)
monitor = desktop.monitor
point_layout_at(desktop, monitor) if monitor
current = active_monitor&.active_desktop ||
@primary_monitor&.active_desktop
@current_desktop_id = current&.id || 0
publish_desktop_state if publish
desktop
end
def next_automatic_desktop_key
serials = @desktops.filter_map do |desktop|
desktop.key[/\Adesktop-(\d+)\z/, 1]&.to_i
end
serial = [serials.max.to_i + 1, @desktops.length + 1].max
serial += 1 while @desktops.any? { _1.key == "desktop-#{serial}" }
"desktop-#{serial}"
end
# Idempotently resolve a stable project key to an EWMH desktop index.
def ensure_desktop(spec)
key = validated_desktop_key(spec["key"])
mode_name = (spec["mode"] || "tiled").to_s
unless %w[tiled floating].include?(mode_name)
raise ArgumentError, "mode must be tiled or floating"
end
mode = mode_name.to_sym
if desktop = @desktops.find { _1.key == key }
if spec.key?("group") && spec.key?("rename_group")
raise ArgumentError, "group assignment and group re-key cannot be combined"
end
new_key = if spec.key?("new_key")
validated_desktop_key(spec["new_key"], field: "new desktop key")
else
desktop.key
end
if new_key != desktop.key &&
@desktops.any? { _1 != desktop && _1.key == new_key }
raise ArgumentError, "desktop key is already in use: #{new_key}"
end
if spec.key?("mode") && desktop.mode != mode
unless desktop.children.empty?
raise ArgumentError, "a non-empty desktop's mode cannot be changed"
end
end
metadata = {
"name" => [:name, 512],
"project" => [:project, 4096],
"group" => [:group, 512],
}.filter_map do |field, (attribute, limit)|
next unless spec.key?(field)
value = desktop_metadata_string(spec[field], field, limit)
raise ArgumentError, "name cannot be empty" if field == "name" &&
(value.nil? || value.empty?)
[attribute, value]
end
new_group = nil
group_members = []
if spec.key?("rename_group")
new_group = desktop_metadata_string(
spec["rename_group"], "group", 512
).to_s.strip
raise ArgumentError, "new group key cannot be empty" if new_group.empty?
old_group = desktop.group.to_s.strip
if !old_group.empty? && old_group != new_group
group_members = @desktops.select {
_1.group.to_s.strip == old_group
}
if @desktops.any? {
!group_members.include?(_1) && _1.group.to_s.strip == new_group
}
raise ArgumentError, "group key is already in use: #{new_group}"
end
end
end
changed = false
if new_key != desktop.key
desktop.key = new_key
changed = true
end
if spec.key?("mode") && desktop.mode != mode
desktop.mode = mode
desktop.layout = mode == :tiled ?
TiledLayout.new(desktop, desktop.geometry) : nil
changed = true
end
metadata.each do |attribute, value|
next if desktop.public_send(attribute) == value
desktop.public_send("#{attribute}=", value)
changed = true
end
if new_group
targets = group_members.empty? ? [desktop] : group_members
targets.each do |member|
next if member.group.to_s.strip == new_group
member.group = new_group
changed = true
end
end
if spec["pinned"] && !desktop.pinned?
desktop.flags |= Desktop::FLAG_PINNED
changed = true
end
if changed && desktop.available_placeholder?
desktop.flags &= ~Desktop::FLAG_AVAILABLE
end
if secondary_group_slot?(desktop)
rehome_group_child_to_tail(desktop, publish: false)
changed = true
end
publish_desktop_state if changed
return {
ok: true,
desktop: desktop.id,
key: desktop.key,
created: false,
updated: changed,
}
end
if spec["update_only"]
raise ArgumentError, "desktop key does not exist: #{key}"
end
raise ArgumentError, "desktop limit reached" if @desktops.length >= 256
name = desktop_metadata_string(spec["name"] || key, "name", 512)
raise ArgumentError, "name cannot be empty" if name.empty?
project = desktop_metadata_string(spec["project"], "project", 4096)
group = desktop_metadata_string(spec["group"], "group", 512)
flags = spec["pinned"] ? Desktop::FLAG_PINNED : 0
candidate = reusable_desktop unless group_already_addressed?(group)
desktop = if candidate
lease_desktop(
candidate,
key: key,
name: name,
project: project,
group: group,
mode: mode,
flags: flags,
).tap { publish_desktop_state }
else
append_desktop(
key: key,
name: name,
project: project,
group: group,
mode: mode,
flags: flags,
)
end
{
ok: true,
desktop: desktop.id,
key: key,
created: true,
leased: desktop.slot_backed?,
}
end
def validated_desktop_key(value, field: "desktop key")
value = value.to_s
raise ArgumentError, "#{field} is required" if value.empty?
raise ArgumentError, "#{field} contains a NUL" if value.include?("\0")
raise ArgumentError, "#{field} is too long" if value.bytesize > 512
value
end
def desktop_metadata_string(value, field, limit)
return if value.nil?
value = value.to_s
raise ArgumentError, "#{field} contains a NUL" if value.include?("\0")
raise ArgumentError, "#{field} is too long" if value.bytesize > limit
value
end
def replacement_desktop_for(target, preferred, occupied)
candidates = [preferred, *@desktops].compact.uniq
candidates.find { _1 != target && !occupied.include?(_1) }