pure-x11 0.0.13 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a246edf65b1a46b6c5e81c24757ca4f48fd5ca8bf4e8ae07c02226728e5ffd32
4
- data.tar.gz: 3f38198bc1e2c7aa77b95ce4069558c616244ce2985589baad32d9af9103d411
3
+ metadata.gz: bfea2e2042ed90f291727231b653878eb36f6f42b79dcdea63a247c912a663c3
4
+ data.tar.gz: 0f1a025486ca695ed9941cf9061f082f1c3838cff15b4c559720f64d28b228b3
5
5
  SHA512:
6
- metadata.gz: 6e422c72294013117c23c2b85b3f85f7272b0abb0114762f98b94e079cbdaaaaac3d420d0313fb8464ab2698c5e476444cfec01cd45346252ab0701d74c6c310
7
- data.tar.gz: 5fc3dfe0aaa23985fa2cb3d2bfa62f94b9d6e902501890f4f8ea43205bbe907cfb0060965846b359ef24de0201776046577d1cb16e097f114db345edef6ec755
6
+ metadata.gz: d27618c1fb2c908ce4c9f2f4a4b7692f369d61ec968b7d9664f021cabc0fd032d7b8df11fb773631d49ce6d876c3eb75584c9bda28f4237bb581dc04b7928176
7
+ data.tar.gz: 063be0a3b3dce1b4b15e57e2121b9c76ed5b65cac5369f9fc3032e133b8cd742d91907f94511d9805f6147d9f8b94b9a2d563e1645dcc94fd2cc507311b18133
@@ -0,0 +1,827 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative '../lib/X11'
3
+
4
+ # A *minimal* System Tray implementation for X11 using the ruby-x11 library
5
+ # Based on the freedesktop.org System Tray Protocol Specification
6
+ # http://standards.freedesktop.org/systemtray-spec/systemtray-spec-latest.html
7
+ #
8
+ # **CAUTION**: This is written entirely by Claude, except for this comment,
9
+ # and has not been thoroughly checked. It works, but may contain all kinds of
10
+ # stupidity that's not necessary... A future version will clean this up.
11
+
12
+ class SystemTray
13
+ # Constants for system tray
14
+ SYSTEM_TRAY_ORIENTATION_HORZ = 0
15
+ SYSTEM_TRAY_ORIENTATION_VERT = 1
16
+
17
+ # X11 atom constants from X11::Form::Atoms
18
+ STRING_ATOM = 31 # X11::Form::Atoms::STRING
19
+
20
+ # Predefined XEMBED message codes
21
+ XEMBED_EMBEDDED_NOTIFY = 0
22
+ XEMBED_WINDOW_ACTIVATE = 1
23
+ XEMBED_WINDOW_DEACTIVATE = 2
24
+ XEMBED_REQUEST_FOCUS = 3
25
+ XEMBED_FOCUS_IN = 4
26
+ XEMBED_FOCUS_OUT = 5
27
+ XEMBED_FOCUS_NEXT = 6
28
+ XEMBED_FOCUS_PREV = 7
29
+
30
+ # Flags for _XEMBED_INFO
31
+ XEMBED_MAPPED = (1 << 0)
32
+
33
+ # Version for the XEMBED protocol
34
+ XEMBED_VERSION = 0
35
+
36
+ # Default icon size
37
+ ICON_DEFAULT_SIZE = 24
38
+
39
+ def initialize(panel_width, panel_height, screen_number = 0, debug = true, dark_mode = false)
40
+ @panel_width = panel_width
41
+ @panel_height = panel_height
42
+ @screen_number = screen_number
43
+ @embedded_icons = {} # Map of window IDs to icon objects
44
+ @tray_atom = nil
45
+ @display = nil
46
+ @tray_window = nil
47
+ @initialized = false
48
+ @orientation = SYSTEM_TRAY_ORIENTATION_HORZ
49
+ @debug = debug
50
+ @dark_mode = dark_mode
51
+
52
+ # Log settings if debug enabled
53
+ puts "Initializing SystemTray with size #{panel_width}x#{panel_height}, screen #{screen_number}" if @debug
54
+ puts "Dark mode enabled" if @debug && @dark_mode
55
+ end
56
+
57
+ def initialize_tray
58
+ # Connect to X display
59
+ @display = X11::Display.new
60
+
61
+ # Create the tray window with additional properties needed for it to be a valid selection owner
62
+ screen = @display.screens.first
63
+ root = @display.default_root
64
+
65
+ # Configure appropriate colors for light/dark mode
66
+ if @dark_mode
67
+ bg_color = screen.black_pixel
68
+ border_color = 0x444444 # Dark gray border
69
+ else
70
+ bg_color = screen.white_pixel
71
+ border_color = screen.black_pixel
72
+ end
73
+
74
+ @tray_window = @display.create_window(
75
+ 100, 100, # position - make it visible for debugging
76
+ @panel_width, @panel_height, # size
77
+ border_width: 1, # Add a visible border for debugging
78
+ parent: root, # Explicitly specify the root window as parent
79
+ depth: screen.root_depth, # Use the root depth to avoid format issues
80
+ wclass: X11::Form::InputOutput,
81
+ values: {
82
+ X11::Form::CWBackPixel => bg_color, # Background color based on mode
83
+ X11::Form::CWBorderPixel => border_color, # Border color based on mode
84
+ X11::Form::CWEventMask => X11::Form::StructureNotifyMask |
85
+ X11::Form::SubstructureNotifyMask |
86
+ X11::Form::SubstructureRedirectMask |
87
+ X11::Form::ExposureMask |
88
+ X11::Form::PropertyChangeMask,
89
+ X11::Form::CWOverrideRedirect => 1 # Prevent window manager from managing this window
90
+ }
91
+ )
92
+
93
+ puts "Created tray window with ID: #{@tray_window}" if @debug
94
+
95
+ # Map the window to make it visible and valid for selection ownership
96
+ @display.map_window(@tray_window)
97
+
98
+ puts "Initializing atoms..." if @debug
99
+
100
+ # Create the selection atom name (screen specific)
101
+ selection_atom_name = "_NET_SYSTEM_TRAY_S#{@screen_number}"
102
+
103
+ # Initialize our needed atoms
104
+ @atoms = {
105
+ "MANAGER" => @display.atom("MANAGER"),
106
+ "_NET_SYSTEM_TRAY_OPCODE" => @display.atom("_NET_SYSTEM_TRAY_OPCODE"),
107
+ "_NET_SYSTEM_TRAY_MESSAGE_DATA" => @display.atom("_NET_SYSTEM_TRAY_MESSAGE_DATA"),
108
+ "_NET_SYSTEM_TRAY_ORIENTATION" => @display.atom("_NET_SYSTEM_TRAY_ORIENTATION"),
109
+ "_XEMBED_INFO" => @display.atom("_XEMBED_INFO"),
110
+ "_XEMBED" => @display.atom("_XEMBED")
111
+ }
112
+
113
+ # Get the tray selection atom
114
+ @tray_atom = @display.atom(selection_atom_name)
115
+
116
+ # Attempt to acquire ownership of the system tray selection
117
+ # CurrentTime = 0 in X11
118
+ timestamp = 0
119
+
120
+ # Set ourselves as the owner
121
+ @display.set_selection_owner(@tray_atom, @tray_window, timestamp)
122
+
123
+ # Give a moment for the server to process
124
+ sleep(0.1)
125
+
126
+ # Check if we really got the selection
127
+ owner = @display.get_selection_owner(@tray_atom)
128
+ if owner == @tray_window
129
+ puts "Successfully acquired system tray selection ownership" if @debug
130
+ else
131
+ puts "Failed to acquire system tray selection ownership"
132
+ if owner != 0
133
+ puts "Another system tray is likely running"
134
+ return false
135
+ end
136
+ end
137
+
138
+ # Announce that we're the system tray manager by sending a MANAGER client message
139
+ send_manager_notification
140
+
141
+ # Set properties on our tray window
142
+ set_tray_orientation(SYSTEM_TRAY_ORIENTATION_HORZ) # Default to horizontal
143
+
144
+ # Set theme-related properties that might help client applications detect dark mode
145
+ if @dark_mode
146
+ # Set some common properties that applications might check for dark mode
147
+ # GTK_THEME_VARIANT property (used by some GTK applications)
148
+ @atoms["_GTK_THEME_VARIANT"] = @display.atom("_GTK_THEME_VARIANT")
149
+
150
+ # Create dark string with proper padding to 4 byte boundary
151
+ dark_str = "dark".unpack("C*")
152
+ # Padding to multiple of 4 bytes
153
+ while (dark_str.length % 4) != 0
154
+ dark_str.push(0) # Add null padding
155
+ end
156
+
157
+ @display.change_property(
158
+ X11::Form::Replace,
159
+ @tray_window,
160
+ @atoms["_GTK_THEME_VARIANT"],
161
+ STRING_ATOM, # Use STRING atom type (31)
162
+ 8, # 8 bits per element
163
+ dark_str
164
+ )
165
+
166
+ # QT_STYLE_OVERRIDE property (used by some Qt applications)
167
+ @atoms["QT_STYLE_OVERRIDE"] = @display.atom("QT_STYLE_OVERRIDE")
168
+
169
+ # Create fusion string with proper padding to 4 byte boundary
170
+ fusion_str = "Fusion".unpack("C*")
171
+ # Padding to multiple of 4 bytes
172
+ while (fusion_str.length % 4) != 0
173
+ fusion_str.push(0) # Add null padding
174
+ end
175
+
176
+ @display.change_property(
177
+ X11::Form::Replace,
178
+ @tray_window,
179
+ @atoms["QT_STYLE_OVERRIDE"],
180
+ STRING_ATOM, # Use STRING atom type (31)
181
+ 8,
182
+ fusion_str
183
+ )
184
+
185
+ puts "Set dark mode theme properties" if @debug
186
+ end
187
+
188
+ @initialized = true
189
+ @display.map_window(@tray_window)
190
+ true
191
+ end
192
+
193
+ def send_manager_notification
194
+ # Send MANAGER client message to the root window to announce our presence
195
+ # According to the System Tray Protocol:
196
+ # - window = root window
197
+ # - message_type = MANAGER
198
+ # - format = 32
199
+ # - data[0] = timestamp when the manager selection was acquired
200
+ # - data[1] = selection atom (_NET_SYSTEM_TRAY_Sn)
201
+ # - data[2] = manager window
202
+
203
+ root = @display.default_root
204
+ timestamp = 0 # CurrentTime = 0 in X11
205
+
206
+ # Data array with trailing zeros
207
+ event_data = [timestamp, @tray_atom, @tray_window, 0, 0]
208
+
209
+ # Send the client message
210
+ @display.client_message(
211
+ window: root,
212
+ type: @atoms["MANAGER"],
213
+ format: 32,
214
+ destination: root,
215
+ mask: X11::Form::StructureNotifyMask,
216
+ data: event_data
217
+ )
218
+ end
219
+
220
+ def set_tray_orientation(orientation)
221
+ # Set the orientation property on our tray window
222
+ @orientation = orientation
223
+
224
+ # Create binary representation of the orientation value
225
+ data = [orientation].pack("L").unpack("C*")
226
+
227
+ # Set the property on our window
228
+ @display.change_property(
229
+ X11::Form::Replace,
230
+ @tray_window,
231
+ @atoms["_NET_SYSTEM_TRAY_ORIENTATION"],
232
+ X11::Form::CardinalAtom,
233
+ 32,
234
+ data
235
+ )
236
+ end
237
+
238
+ def handle_client_message(event)
239
+ begin
240
+ # Check if window is our tray window or one of our icon windows
241
+ # This helps filter out messages not meant for us
242
+ is_our_window = (event.window == @tray_window) || @embedded_icons.key?(event.window)
243
+
244
+ if event.type == @atoms["_NET_SYSTEM_TRAY_OPCODE"] && event.format == 32
245
+ puts "Received _NET_SYSTEM_TRAY_OPCODE message to window #{event.window}"
246
+
247
+ # For opcode messages, data layout is:
248
+ # l[0] = timestamp
249
+ # l[1] = opcode (SYSTEM_TRAY_REQUEST_DOCK = 0)
250
+ # l[2] = icon window ID (for dock requests)
251
+ # l[3] = data1
252
+ # l[4] = data2
253
+
254
+ # Extract data values (using little-endian format)
255
+ data_values = event.data.unpack("L5")
256
+
257
+ if data_values && data_values.length >= 3
258
+ timestamp = data_values[0]
259
+ opcode = data_values[1]
260
+
261
+ case opcode
262
+ when 0 # SYSTEM_TRAY_REQUEST_DOCK
263
+ icon_window = data_values[2]
264
+ puts "Received dock request for window: #{icon_window}"
265
+ dock_icon(icon_window)
266
+ when 1 # SYSTEM_TRAY_BEGIN_MESSAGE
267
+ # Balloon message handling - would need to create a popup window
268
+ puts "Begin message received, data: #{data_values.inspect}"
269
+ when 2 # SYSTEM_TRAY_CANCEL_MESSAGE
270
+ # Cancel balloon message
271
+ puts "Cancel message received, message ID: #{data_values[2]}"
272
+ end
273
+ end
274
+ elsif event.type == @atoms["_XEMBED"] && event.format == 32
275
+ # Extract XEMBED protocol data
276
+ data_values = event.data.unpack("L5")
277
+
278
+ if data_values && data_values.length >= 3
279
+ timestamp = data_values[0]
280
+ message = data_values[1]
281
+ detail = data_values[2]
282
+
283
+ puts "Received XEMBED message: #{message}, detail: #{detail} for window #{event.window}"
284
+
285
+ case message
286
+ when XEMBED_REQUEST_FOCUS
287
+ # Icon is requesting focus
288
+ puts "Icon requested focus: #{event.window}"
289
+ # We could potentially focus the application here
290
+ when XEMBED_FOCUS_IN, XEMBED_FOCUS_OUT
291
+ # Focus events - could be used to highlight active icon
292
+ puts "Focus #{message == XEMBED_FOCUS_IN ? 'in' : 'out'} event for window #{event.window}"
293
+ end
294
+ end
295
+ elsif is_our_window
296
+ # Other client messages that might be relevant
297
+ puts "Received client message of type #{@display.get_atom_name(event.type) || event.type} to window #{event.window}"
298
+ end
299
+ rescue => e
300
+ # Error handling to prevent crashes on malformed messages
301
+ puts "Error processing client message: #{e.message}"
302
+ puts e.backtrace.join("\n") if @debug
303
+ end
304
+ end
305
+
306
+ def dock_icon(icon_window)
307
+ # Don't dock the same window twice
308
+ return if @embedded_icons.key?(icon_window)
309
+
310
+ puts "Attempting to dock icon window #{icon_window}" if @debug
311
+
312
+ # Check if the window exists
313
+ attributes = @display.get_window_attributes(icon_window)
314
+ if !attributes
315
+ puts "Window #{icon_window} doesn't exist or can't be accessed"
316
+ return
317
+ end
318
+
319
+ # Add event mask to the icon window
320
+ event_mask = X11::Form::StructureNotifyMask | X11::Form::PropertyChangeMask
321
+ @display.change_window_attributes(
322
+ icon_window,
323
+ values: {
324
+ X11::Form::CWEventMask => event_mask
325
+ }
326
+ )
327
+
328
+ # Create an icon object to track this window with default size
329
+ icon = {
330
+ "window" => icon_window,
331
+ "width" => ICON_DEFAULT_SIZE,
332
+ "height" => ICON_DEFAULT_SIZE,
333
+ "visible" => false
334
+ }
335
+
336
+ # Try to get geometry info for better sizing
337
+ begin
338
+ geometry = @display.get_geometry(icon_window)
339
+ if geometry
340
+ puts "Icon geometry: #{geometry.width}x#{geometry.height}" if @debug
341
+
342
+ # Check for tiny icons (some apps create 1x1 icons)
343
+ if geometry.width < 4 || geometry.height < 4
344
+ puts "Ignoring very small icon size (#{geometry.width}x#{geometry.height}), using default" if @debug
345
+ # Keep the default size
346
+ else
347
+ # Use the actual geometry
348
+ icon["width"] = geometry.width
349
+ icon["height"] = geometry.height
350
+ end
351
+ end
352
+ rescue => e
353
+ puts "Error getting geometry: #{e.message}" if @debug
354
+ # Keep default size
355
+ end
356
+
357
+ # Resize the icon window to match our desired size
358
+ @display.configure_window(
359
+ icon_window,
360
+ width: icon["width"],
361
+ height: icon["height"]
362
+ )
363
+
364
+ # Reparent the icon window to our tray window (this is the actual embedding)
365
+ @display.reparent_window(icon_window, @tray_window, 0, 0)
366
+
367
+ # If dark mode is enabled, try to set some properties on the icon window
368
+ # to encourage applications to use dark mode icons if available
369
+ if @dark_mode
370
+ begin
371
+ # Set a hint that we're in dark mode (some apps might check for this)
372
+ @atoms["_XEMBED_INFO_DARKMODE"] = @display.atom("_XEMBED_INFO_DARKMODE")
373
+ @display.change_property(
374
+ X11::Form::Replace,
375
+ icon_window,
376
+ @atoms["_XEMBED_INFO_DARKMODE"],
377
+ X11::Form::CardinalAtom,
378
+ 32,
379
+ [1] # 1 = dark mode enabled
380
+ )
381
+
382
+ # Set GTK dark mode hint on the icon window too
383
+ @atoms["_GTK_THEME_VARIANT"] = @display.atom("_GTK_THEME_VARIANT")
384
+
385
+ # Create dark string with proper padding to 4 byte boundary
386
+ dark_str = "dark".unpack("C*")
387
+ # Padding to multiple of 4 bytes
388
+ while (dark_str.length % 4) != 0
389
+ dark_str.push(0) # Add null padding
390
+ end
391
+
392
+ @display.change_property(
393
+ X11::Form::Replace,
394
+ icon_window,
395
+ @atoms["_GTK_THEME_VARIANT"],
396
+ STRING_ATOM, # Use STRING atom type (31)
397
+ 8, # 8 bits per element
398
+ dark_str
399
+ )
400
+
401
+ puts "Set dark mode hints on icon window #{icon_window}" if @debug
402
+ rescue => e
403
+ puts "Error setting dark mode properties on icon: #{e.message}" if @debug
404
+ end
405
+ end
406
+
407
+ # Get the XEMBED_INFO property
408
+ get_xembed_info(icon)
409
+
410
+ # Store the icon in our tracking map
411
+ @embedded_icons[icon_window] = icon
412
+
413
+ # Send XEMBED message to tell the icon it's embedded
414
+ send_xembed_message(
415
+ icon_window,
416
+ XEMBED_EMBEDDED_NOTIFY,
417
+ 0, # detail
418
+ @tray_window, # embed_info_window
419
+ XEMBED_VERSION
420
+ )
421
+
422
+ # Always make the icon visible since reparenting may have unmapped it
423
+ @display.map_window(icon_window)
424
+ icon["visible"] = true
425
+ puts "Mapped icon window #{icon_window}" if @debug
426
+
427
+ # Make sure our tray window is visible
428
+ @display.map_window(@tray_window)
429
+
430
+ # Update the layout of all icons
431
+ layout_icons
432
+ end
433
+
434
+ def get_xembed_info(icon)
435
+ return unless icon # Safety check
436
+ window_id = icon["window"]
437
+
438
+ begin
439
+ puts "Getting XEMBED_INFO for window #{window_id}" if @debug
440
+
441
+ # First verify window still exists
442
+ begin
443
+ attributes = @display.get_window_attributes(window_id)
444
+ unless attributes
445
+ puts "Window #{window_id} no longer exists" if @debug
446
+ return false
447
+ end
448
+ rescue => e
449
+ puts "Error checking window attributes: #{e.message}" if @debug
450
+ return false
451
+ end
452
+
453
+ # Get XEMBED_INFO property from the icon window
454
+ begin
455
+ result = @display.get_property(
456
+ window_id,
457
+ @atoms["_XEMBED_INFO"],
458
+ @atoms["_XEMBED_INFO"]
459
+ )
460
+ rescue => e
461
+ puts "Error getting XEMBED_INFO property: #{e.message}" if @debug
462
+ # Default to mapped if we can't get the property
463
+ icon["xembed_version"] = XEMBED_VERSION
464
+ icon["xembed_flags"] = XEMBED_MAPPED
465
+ @display.map_window(window_id) rescue nil
466
+ icon["visible"] = true
467
+ return true
468
+ end
469
+
470
+ # If we got a valid result with data
471
+ if result && result.value && result.value.is_a?(Array) && result.value.length >= 2
472
+ # Extract version and flags
473
+ version = result.value[0]
474
+ flags = result.value[1]
475
+
476
+ puts "XEMBED_INFO: version=#{version}, flags=#{flags}" if @debug
477
+
478
+ # Store in our icon object
479
+ icon["xembed_version"] = version
480
+ icon["xembed_flags"] = flags
481
+
482
+ # Check if the icon wants to be mapped (XEMBED_MAPPED = bit 0)
483
+ if (flags & XEMBED_MAPPED) != 0
484
+ puts "XEMBED_MAPPED flag is set, mapping window" if @debug
485
+ @display.map_window(window_id) rescue nil
486
+ icon["visible"] = true
487
+ else
488
+ puts "XEMBED_MAPPED flag is not set" if @debug
489
+ end
490
+ else
491
+ # Even without XEMBED_INFO, we should map the window by default
492
+ # This helps with clients that don't set the property correctly
493
+ puts "No valid XEMBED_INFO property found, using defaults" if @debug
494
+ icon["xembed_version"] = XEMBED_VERSION
495
+ icon["xembed_flags"] = XEMBED_MAPPED # Default to mapped
496
+
497
+ # Map the window anyway - most applications expect this
498
+ @display.map_window(window_id) rescue nil
499
+ icon["visible"] = true
500
+ end
501
+
502
+ return true
503
+ rescue => e
504
+ puts "Unexpected error in get_xembed_info: #{e.message}" if @debug
505
+ return false
506
+ end
507
+ end
508
+
509
+ def send_xembed_message(window, message, detail, data1, data2)
510
+ # Send an XEMBED client message to a specific window
511
+ timestamp = Time.now.to_i
512
+
513
+ # Send the message
514
+ @display.client_message(
515
+ window: window,
516
+ type: @atoms["_XEMBED"],
517
+ format: 32,
518
+ destination: window,
519
+ mask: 0, # NoEventMask
520
+ data: [timestamp, message, detail, data1, data2]
521
+ )
522
+ end
523
+
524
+ def layout_icons
525
+ # Calculate the layout of icons in the tray
526
+ puts "Laying out icons, count: #{@embedded_icons.size}" if @debug
527
+
528
+ # Layout logic depends on orientation
529
+ spacing = 2 # pixels between icons
530
+ padding = 2 # padding inside tray
531
+
532
+ # Calculate required space
533
+ visible_icons = @embedded_icons.values.select { |icon| icon["visible"] }
534
+
535
+ if @orientation == SYSTEM_TRAY_ORIENTATION_HORZ
536
+ # Horizontal layout - calculate total width needed
537
+ total_width = visible_icons.inject(0) { |sum, icon| sum + icon["width"] }
538
+ total_width += (visible_icons.size - 1) * spacing if visible_icons.size > 1
539
+ total_width += padding * 2 # Add padding on both sides
540
+
541
+ # Use either the calculated width or minimum panel width, whichever is larger
542
+ actual_width = [total_width, @panel_width].max
543
+ actual_height = @panel_height
544
+ else
545
+ # Vertical layout - calculate total height needed
546
+ total_height = visible_icons.inject(0) { |sum, icon| sum + icon["height"] }
547
+ total_height += (visible_icons.size - 1) * spacing if visible_icons.size > 1
548
+ total_height += padding * 2 # Add padding on both sides
549
+
550
+ # Use either the calculated height or minimum panel height, whichever is larger
551
+ actual_width = @panel_width
552
+ actual_height = [total_height, @panel_height].max
553
+ end
554
+
555
+ # Resize the tray window if needed
556
+ @display.configure_window(@tray_window, width: actual_width, height: actual_height)
557
+
558
+ # Position icons
559
+ if @orientation == SYSTEM_TRAY_ORIENTATION_HORZ
560
+ # Horizontal layout
561
+ x = padding
562
+ visible_icons.each do |icon|
563
+ # Get the available panel height
564
+ panel_height = actual_height - (padding * 2)
565
+
566
+ # Handle tiny icons (height < 4 or width < 4) by using default size
567
+ if icon["height"] < 4 || icon["width"] < 4
568
+ puts "Found tiny icon (#{icon["width"]}x#{icon["height"]}), will scale up" if @debug
569
+ # Use the default icon size instead
570
+ icon["width"] = ICON_DEFAULT_SIZE
571
+ icon["height"] = ICON_DEFAULT_SIZE
572
+ end
573
+
574
+ # Get the aspect ratio for scaling
575
+ aspect_ratio = icon["width"].to_f / icon["height"]
576
+
577
+ # Check if the icon is smaller than the panel height
578
+ if icon["height"] < panel_height
579
+ puts "Icon height (#{icon["height"]}) is smaller than panel (#{panel_height}), scaling up" if @debug
580
+ # Scale to fill height of the panel
581
+ icon_height = panel_height
582
+ icon_width = (icon_height * aspect_ratio).to_i
583
+ else
584
+ # Keep original size if larger than panel
585
+ icon_height = icon["height"]
586
+ icon_width = icon["width"]
587
+ end
588
+
589
+ # Center vertically
590
+ y = (actual_height - icon_height) / 2
591
+
592
+ puts "Positioning icon #{icon["window"]} at x=#{x}, y=#{y}, size=#{icon_width}x#{icon_height}" if @debug
593
+
594
+ # Move and resize the icon window
595
+ @display.configure_window(
596
+ icon["window"],
597
+ x: x,
598
+ y: y,
599
+ width: icon_width,
600
+ height: icon_height
601
+ )
602
+
603
+ x += icon_width + spacing
604
+ end
605
+ else
606
+ # Vertical layout
607
+ y = padding
608
+ visible_icons.each do |icon|
609
+ # Get the available panel width
610
+ panel_width = actual_width - (padding * 2)
611
+
612
+ # Handle tiny icons (height < 4 or width < 4) by using default size
613
+ if icon["height"] < 4 || icon["width"] < 4
614
+ puts "Found tiny icon (#{icon["width"]}x#{icon["height"]}), will scale up" if @debug
615
+ # Use the default icon size instead
616
+ icon["width"] = ICON_DEFAULT_SIZE
617
+ icon["height"] = ICON_DEFAULT_SIZE
618
+ end
619
+
620
+ # Get the aspect ratio for scaling
621
+ aspect_ratio = icon["height"].to_f / icon["width"]
622
+
623
+ # Check if the icon is smaller than the panel width
624
+ if icon["width"] < panel_width
625
+ puts "Icon width (#{icon["width"]}) is smaller than panel (#{panel_width}), scaling up" if @debug
626
+ # Scale to fill width of the panel
627
+ icon_width = panel_width
628
+ icon_height = (icon_width * aspect_ratio).to_i
629
+ else
630
+ # Keep original size if larger than panel
631
+ icon_width = icon["width"]
632
+ icon_height = icon["height"]
633
+ end
634
+
635
+ # Center horizontally
636
+ x = (actual_width - icon_width) / 2
637
+
638
+ puts "Positioning icon #{icon["window"]} at x=#{x}, y=#{y}, size=#{icon_width}x#{icon_height}" if @debug
639
+
640
+ # Move and resize the icon window
641
+ @display.configure_window(
642
+ icon["window"],
643
+ x: x,
644
+ y: y,
645
+ width: icon_width,
646
+ height: icon_height
647
+ )
648
+
649
+ y += icon_height + spacing
650
+ end
651
+ end
652
+
653
+ # Make sure all windows are mapped
654
+ visible_icons.each do |icon|
655
+ @display.map_window(icon["window"])
656
+ end
657
+
658
+ # Make sure our changes are sent to the server
659
+ @display.flush
660
+ end
661
+
662
+ def handle_icon_destroyed(window)
663
+ # Called when an icon window is destroyed
664
+ if @embedded_icons.key?(window)
665
+ @embedded_icons.delete(window)
666
+ layout_icons
667
+ end
668
+ end
669
+
670
+ def handle_icon_configure(window, width, height)
671
+ # Called when an icon window changes size
672
+ if @embedded_icons.key?(window)
673
+ @embedded_icons[window]["width"] = width
674
+ @embedded_icons[window]["height"] = height
675
+ layout_icons
676
+ end
677
+ end
678
+
679
+ def handle_icon_map(window)
680
+ # Called when an icon window is mapped (made visible)
681
+ if @embedded_icons.key?(window)
682
+ @embedded_icons[window]["visible"] = true
683
+ layout_icons
684
+ end
685
+ end
686
+
687
+ def handle_icon_unmap(window)
688
+ # Called when an icon window is unmapped (hidden)
689
+ if @embedded_icons.key?(window)
690
+ @embedded_icons[window]["visible"] = false
691
+ layout_icons
692
+ end
693
+ end
694
+
695
+ def process_events
696
+ # Main event processing loop
697
+ puts "Processing systray events (press Ctrl+C to exit)..."
698
+
699
+ @display.run do |event|
700
+ case event
701
+ when X11::Form::ClientMessage
702
+ handle_client_message(event)
703
+ when X11::Form::DestroyNotify
704
+ handle_icon_destroyed(event.window) if event.window != @tray_window
705
+ when X11::Form::ConfigureNotify
706
+ handle_icon_configure(event.window, event.width, event.height) if event.window != @tray_window
707
+ when X11::Form::MapNotify
708
+ handle_icon_map(event.window) if event.window != @tray_window
709
+ when X11::Form::UnmapNotify
710
+ handle_icon_unmap(event.window) if event.window != @tray_window
711
+ when X11::Form::PropertyNotify
712
+ # Handle property change notifications - safely
713
+ begin
714
+ if @embedded_icons.key?(event.window)
715
+ # If the _XEMBED_INFO property changed, update our info
716
+ if event.atom == @atoms["_XEMBED_INFO"]
717
+ puts "XEMBED_INFO property changed for #{event.window}" if @debug
718
+
719
+ # First check if the window still exists
720
+ begin
721
+ attributes = @display.get_window_attributes(event.window)
722
+ if attributes
723
+ get_xembed_info(@embedded_icons[event.window])
724
+ layout_icons
725
+ else
726
+ puts "Window #{event.window} no longer exists, removing from tracked icons" if @debug
727
+ @embedded_icons.delete(event.window)
728
+ layout_icons
729
+ end
730
+ rescue => e
731
+ # Window likely destroyed
732
+ puts "Error checking window attributes: #{e.message}" if @debug
733
+ @embedded_icons.delete(event.window)
734
+ layout_icons
735
+ end
736
+ end
737
+ end
738
+ rescue => e
739
+ puts "Error handling property notification: #{e.message}" if @debug
740
+ end
741
+ end
742
+ end
743
+ end
744
+
745
+ def cleanup
746
+ # Release the selection ownership
747
+ if @initialized && @display
748
+ # Set owner to None (0) to release the selection
749
+ @display.set_selection_owner(@tray_atom, 0, 0)
750
+ puts "Released system tray selection ownership" if @debug
751
+
752
+ # Destroy tray window
753
+ @display.destroy_window(@tray_window)
754
+ puts "Destroyed tray window" if @debug
755
+
756
+ # Close display - handled by ruby-x11's at_exit
757
+ end
758
+ end
759
+ end
760
+
761
+ # Simple example usage
762
+ if __FILE__ == $0
763
+ require 'optparse'
764
+
765
+ options = {
766
+ width: 240,
767
+ height: 30,
768
+ screen: 0,
769
+ debug: false,
770
+ dark_mode: false
771
+ }
772
+
773
+ # Parse command line options
774
+ OptionParser.new do |opts|
775
+ opts.banner = "Usage: #{$0} [options]"
776
+
777
+ opts.on("-w", "--width WIDTH", Integer, "Width of the tray (default: #{options[:width]})") do |w|
778
+ options[:width] = w
779
+ end
780
+
781
+ opts.on("-h", "--height HEIGHT", Integer, "Height of the tray (default: #{options[:height]})") do |h|
782
+ options[:height] = h
783
+ end
784
+
785
+ opts.on("-s", "--screen SCREEN", Integer, "Screen number (default: #{options[:screen]})") do |s|
786
+ options[:screen] = s
787
+ end
788
+
789
+ opts.on("-d", "--debug", "Enable debug output") do
790
+ options[:debug] = true
791
+ end
792
+
793
+ opts.on("--dark-mode", "Enable dark mode") do
794
+ options[:dark_mode] = true
795
+ end
796
+
797
+ opts.on("--help", "Show this help message") do
798
+ puts opts
799
+ exit
800
+ end
801
+ end.parse!
802
+
803
+ puts "Starting system tray example (#{options[:width]}x#{options[:height]} on screen #{options[:screen]})"
804
+ puts "Debug output enabled" if options[:debug]
805
+ puts "Dark mode enabled" if options[:dark_mode]
806
+ puts "\nWaiting for systray applications (e.g., nm-applet, volumeicon, etc.)"
807
+ puts "Tip: Run in another terminal: 'nm-applet' or other systray-enabled applications"
808
+
809
+ # Create system tray with the specified options
810
+ tray = SystemTray.new(options[:width], options[:height], options[:screen], options[:debug], options[:dark_mode])
811
+
812
+ # Initialize the tray
813
+ if tray.initialize_tray
814
+ puts "System tray initialized successfully"
815
+
816
+ # Process events
817
+ begin
818
+ tray.process_events
819
+ rescue Interrupt
820
+ puts "\nInterrupted, cleaning up..."
821
+ ensure
822
+ tray.cleanup
823
+ end
824
+ else
825
+ puts "Failed to initialize system tray"
826
+ end
827
+ end
data/lib/X11/display.rb CHANGED
@@ -358,6 +358,41 @@ module X11
358
358
  def destroy_window(window) = write_request(Form::DestroyWindow.new(window))
359
359
  def get_geometry(drawable) = write_sync(Form::GetGeometry.new(drawable), Form::Geometry)
360
360
 
361
+ # Set the owner of a selection
362
+ # selection: the selection atom
363
+ # owner: the window ID of the new owner, or None (0) to indicate no owner
364
+ # time: the server time when ownership should take effect, or CurrentTime (0)
365
+ def set_selection_owner(selection, owner, time = 0)
366
+ # Convert selection to atom ID if necessary
367
+ selection = atom(selection) if selection.is_a?(Symbol) || selection.is_a?(String)
368
+ owner = owner || 0 # Allow nil for owner to mean None (0)
369
+
370
+ # Create and send the SetSelectionOwner request using the Form
371
+ req = Form::SetSelectionOwner.new(owner, selection, time)
372
+ write_request(req)
373
+
374
+ true # Always returns true; check get_selection_owner to verify
375
+ end
376
+
377
+ # Get the current owner of a selection
378
+ # selection: the selection atom
379
+ # Returns: the window ID of the owner, or None (0) if there is no owner
380
+ def get_selection_owner(selection)
381
+ # Convert selection to atom ID if necessary
382
+ selection = atom(selection) if selection.is_a?(Symbol) || selection.is_a?(String)
383
+
384
+ # Use the form-based approach for reading
385
+ req = Form::GetSelectionOwner.new(selection)
386
+
387
+ begin
388
+ reply = write_sync(req, Form::SelectionOwner)
389
+ reply ? reply.owner : 0
390
+ rescue => e
391
+ STDERR.puts "Error getting selection owner: #{e.message}" if @debug
392
+ 0 # Return 0 (None) on error
393
+ end
394
+ end
395
+
361
396
  def get_keyboard_mapping(min_keycode=display_info.min_keycode, count= display_info.max_keycode - min_keycode)
362
397
  write_sync(Form::GetKeyboardMapping.new(min_keycode, count), Form::GetKeyboardMappingReply)
363
398
  end
data/lib/X11/form.rb CHANGED
@@ -654,6 +654,30 @@ module X11
654
654
  field :event, Uint32 # FIXME: This is wrong, and will break on parsing.
655
655
  end
656
656
 
657
+ class SetSelectionOwner < BaseForm
658
+ field :opcode, Uint8, value: 22
659
+ unused 1
660
+ field :request_length, Uint16, value: 4
661
+ field :owner, Window # Window - NOTE: order corrected, owner comes first
662
+ field :selection, Atom # Selection atom
663
+ field :time, Uint32
664
+ end
665
+
666
+ class GetSelectionOwner < BaseForm
667
+ field :opcode, Uint8, value: 23
668
+ unused 1
669
+ field :request_length, Uint16, value: 2
670
+ field :selection, Atom # Selection atom
671
+ end
672
+
673
+ class SelectionOwner < Reply
674
+ unused 1
675
+ field :sequence_number, Uint16
676
+ field :reply_length, Uint32
677
+ field :owner, Window
678
+ unused 20
679
+ end
680
+
657
681
  class GrabButton < BaseForm
658
682
  field :opcode, Uint8, value: 28
659
683
  field :owner_events, Bool
data/lib/X11/screen.rb CHANGED
@@ -12,8 +12,10 @@ module X11
12
12
  def root_visual = @internal.root_visual
13
13
  def width = @internal.width_in_pixels
14
14
  def height = @internal.height_in_pixels
15
+ def black_pixel = @internal.black_pixel
16
+ def white_pixel = @internal.white_pixel
15
17
 
16
- def to_s
18
+ def inspect
17
19
  "#<X11::Screen(#{id}) width=#{width} height=#{height}>"
18
20
  end
19
21
  end
data/lib/X11/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module X11
2
- VERSION = "0.0.13"
2
+ VERSION = "0.0.14"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pure-x11
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.13
4
+ version: 0.0.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vidar Hokstad
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2025-05-04 00:00:00.000000000 Z
12
+ date: 2025-05-05 00:00:00.000000000 Z
13
13
  dependencies: []
14
14
  description: Pure Ruby X11 bindings
15
15
  email:
@@ -30,6 +30,7 @@ files:
30
30
  - example/genie.png
31
31
  - example/query_pointer.rb
32
32
  - example/query_pointer_simple.rb
33
+ - example/systray.rb
33
34
  - example/test.rb
34
35
  - example/test_change_property.rb
35
36
  - example/test_intern_atom.rb