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 +4 -4
- data/example/systray.rb +827 -0
- data/lib/X11/display.rb +35 -0
- data/lib/X11/form.rb +24 -0
- data/lib/X11/screen.rb +3 -1
- data/lib/X11/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: bfea2e2042ed90f291727231b653878eb36f6f42b79dcdea63a247c912a663c3
|
4
|
+
data.tar.gz: 0f1a025486ca695ed9941cf9061f082f1c3838cff15b4c559720f64d28b228b3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d27618c1fb2c908ce4c9f2f4a4b7692f369d61ec968b7d9664f021cabc0fd032d7b8df11fb773631d49ce6d876c3eb75584c9bda28f4237bb581dc04b7928176
|
7
|
+
data.tar.gz: 063be0a3b3dce1b4b15e57e2121b9c76ed5b65cac5369f9fc3032e133b8cd742d91907f94511d9805f6147d9f8b94b9a2d563e1645dcc94fd2cc507311b18133
|
data/example/systray.rb
ADDED
@@ -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
|
18
|
+
def inspect
|
17
19
|
"#<X11::Screen(#{id}) width=#{width} height=#{height}>"
|
18
20
|
end
|
19
21
|
end
|
data/lib/X11/version.rb
CHANGED
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.
|
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-
|
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
|