__init__.py
Python script, Unicode text, UTF-8 text executable
1
"""
2
Nesting file menu applet for the Panorama panel.
3
Copyright 2025, roundabout-host.com <vlad@roundabout-host.com>
4
5
This program is free software: you can redistribute it and/or modify
6
it under the terms of the GNU General Public Licence as published by
7
the Free Software Foundation, either version 3 of the Licence, or
8
(at your option) any later version.
9
10
This program is distributed in the hope that it will be useful,
11
but WITHOUT ANY WARRANTY; without even the implied warranty of
12
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
GNU General Public Licence for more details.
14
15
You should have received a copy of the GNU General Public Licence
16
along with this program. If not, see <https://www.gnu.org/licenses/>.
17
"""
18
import hashlib
19
import os
20
import locale
21
from pathlib import Path
22
import panorama_panel
23
24
import gi
25
gi.require_version("Gtk", "4.0")
26
27
from gi.repository import Gtk, GLib, Gio, Gdk, GObject, GioUnix
28
29
module_directory = Path(__file__).resolve().parent
30
locale.bindtextdomain("panorama-panel-file-listing", module_directory / "locale")
31
_ = lambda x: locale.dgettext("panorama-panel-file-listing", x)
32
33
34
@Gtk.Template(filename=str(module_directory / "panorama-panel-file-listing.ui"))
35
class FileListingOptions(Gtk.Window):
36
__gtype_name__ = "FileListingOptions"
37
icon_size_adjustment: Gtk.Adjustment = Gtk.Template.Child()
38
icon_name_entry: Gtk.Entry = Gtk.Template.Child()
39
label_entry: Gtk.Label = Gtk.Template.Child()
40
hidden_button: Gtk.CheckButton = Gtk.Template.Child()
41
root_button: Gtk.Button = Gtk.Template.Child()
42
43
@GObject.Signal(arg_types=(str,))
44
def new_path(self, path):
45
self.root_button.set_label(path)
46
47
def open_done(self, browser, result, *args):
48
self.emit("new_path", browser.select_folder_finish(result).get_path())
49
50
def show_browser(self, button):
51
browser = Gtk.FileDialog()
52
53
browser.select_folder(self, None, self.open_done)
54
55
def __init__(self, **kwargs):
56
super().__init__(**kwargs)
57
58
self.root_button.connect("clicked", self.show_browser)
59
60
self.connect("close-request", lambda *args: self.destroy())
61
62
63
class FileMenuAttributeIter(Gio.MenuAttributeIter, GObject.Object):
64
def __init__(self, attributes: dict, **kwargs):
65
GObject.Object.__init__(self, **kwargs)
66
self._attributes: dict = attributes
67
self._values = list(attributes.items())
68
self._i = 0
69
70
def do_get_next(self):
71
if self._i >= len(self._values):
72
return False, None, None
73
74
return True, *self._values[self._i]
75
76
77
class FileMenuLinkIter(Gio.MenuLinkIter, GObject.Object):
78
def __init__(self, attributes: dict, **kwargs):
79
GObject.Object.__init__(self, **kwargs)
80
self._attributes: dict = attributes
81
self._values = list(attributes.items())
82
self._i = 0
83
84
def do_get_next(self):
85
if self._i >= len(self._values):
86
return False, None, None
87
88
return True, *self._values[self._i]
89
90
91
class FileMenu(Gio.MenuModel, GObject.Object):
92
def __init__(self, root: Path, storage: dict[str, GObject.Object], new_action_callback, prune_action_callback, show_hidden=False, **kwargs):
93
GObject.Object.__init__(self, **kwargs)
94
self.root: Path = root
95
self.links: dict[str, Gio.MenuModel] = {}
96
self.files: list[Path] = []
97
self.storage = storage
98
self.callback = new_action_callback
99
self.callback_prune = prune_action_callback
100
self.show_hidden = show_hidden
101
self.enabled = False
102
103
def do_is_mutable(self):
104
return True
105
106
def do_get_n_items(self):
107
if not self.enabled:
108
return 0
109
if self.show_hidden:
110
self.files = sorted(
111
self.root.iterdir(),
112
key=lambda p: GLib.utf8_collate_key_for_filename(p.name, len(p.name))
113
)
114
else:
115
self.files = sorted(
116
(p for p in self.root.iterdir() if not p.name.startswith(".")),
117
key=lambda p: GLib.utf8_collate_key_for_filename(p.name, len(p.name))
118
)
119
return len(self.files)
120
121
def do_get_item_attribute_value(self, item_index, attribute, expected_type = None):
122
file = self.files[item_index]
123
match attribute:
124
case Gio.MENU_ATTRIBUTE_ACTION:
125
return GLib.Variant("s", "applet.open")
126
case Gio.MENU_ATTRIBUTE_TARGET:
127
return GLib.Variant("s", str(file.resolve()))
128
case Gio.MENU_ATTRIBUTE_LABEL:
129
return GLib.Variant("s", file.name.replace("_", "_"))
130
case "submenu-action":
131
return GLib.Variant("s", f"applet.submenu-status-{hashlib.md5(str(file).encode()).hexdigest()}")
132
case _:
133
return None
134
135
def make_submenu(self, name):
136
action_id = hashlib.md5(str(self.root / name).encode())
137
if name not in self.links:
138
menu = FileMenu(self.root / name, self.storage, self.callback, self.callback_prune, show_hidden=self.show_hidden)
139
container = Gio.Menu()
140
options_section = Gio.Menu()
141
open_directory = Gio.MenuItem()
142
open_directory.set_label(_("Open directory"))
143
open_directory.set_action_and_target_value("applet.open", GLib.Variant("s", str((self.root / name).resolve())))
144
options_section.append_item(open_directory)
145
container.append_section(None, options_section)
146
container.append_section(None, menu)
147
container.file_list = menu
148
self.links[name] = container
149
self.storage[action_id.hexdigest()] = menu
150
self.callback(action_id)
151
152
def do_get_item_link(self, item_index, link):
153
file = self.files[item_index]
154
if file.is_dir() and link == Gio.MENU_LINK_SUBMENU:
155
self.make_submenu(file.name)
156
return self.links[file.name]
157
return None
158
159
def do_iterate_item_attributes(self, item_index):
160
file = self.files[item_index]
161
return FileMenuAttributeIter(
162
{
163
Gio.MENU_ATTRIBUTE_ACTION: GLib.Variant("s", "applet.open"),
164
Gio.MENU_ATTRIBUTE_TARGET: GLib.Variant("s", str(file.resolve())),
165
Gio.MENU_ATTRIBUTE_LABEL: GLib.Variant("s", file.name.replace("_", "_")),
166
"submenu-action": GLib.Variant("s", f"applet.submenu-status-{hashlib.md5(str(file).encode()).hexdigest()}"),
167
}
168
)
169
170
def do_iterate_item_links(self, item_index):
171
file = self.files[item_index]
172
if file.is_dir():
173
self.make_submenu(file.name)
174
return FileMenuLinkIter({Gio.MENU_LINK_SUBMENU: self.links[file.name]})
175
return FileMenuLinkIter({})
176
177
def do_get_item_attributes(self, item_index):
178
file = self.files[item_index]
179
return {
180
Gio.MENU_ATTRIBUTE_ACTION: GLib.Variant("s", "applet.open"),
181
Gio.MENU_ATTRIBUTE_TARGET: GLib.Variant("s", str(file.resolve())),
182
Gio.MENU_ATTRIBUTE_LABEL: GLib.Variant("s", file.name.replace("_", "_")),
183
"submenu-action": GLib.Variant("s", f"applet.submenu-status-{hashlib.md5(str(file).encode()).hexdigest()}"),
184
}
185
186
def do_get_item_links(self, item_index):
187
file = self.files[item_index]
188
if file.is_dir():
189
self.make_submenu(file.name)
190
return {Gio.MENU_LINK_SUBMENU: self.links[file.name]}
191
return {}
192
193
def generate(self):
194
self.enabled = True
195
self.items_changed(0, 0, self.get_n_items())
196
197
def prune(self):
198
self.enabled = False
199
for file in self.files:
200
if file.is_dir():
201
action_id = hashlib.md5(str(self.root / file.name).encode())
202
self.links[file.name].file_list.prune()
203
del self.storage[action_id.hexdigest()]
204
self.callback_prune(action_id)
205
self.items_changed(0, len(self.files), 0)
206
self.files.clear()
207
self.links.clear()
208
209
210
class FileListingApplet(panorama_panel.Applet):
211
name = _("File listing")
212
description = _("View a nesting menu of your files")
213
icon = Gio.ThemedIcon.new("system-file-manager")
214
215
def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None, **kwargs):
216
super().__init__(orientation=orientation, config=config, **kwargs)
217
locale.bindtextdomain("panorama-panel-file-listing", module_directory / "locale")
218
_ = lambda x: locale.dgettext("panorama-panel-file-listing", x)
219
if config is None:
220
config = {}
221
self.options_window = None
222
self.action_group = Gio.SimpleActionGroup()
223
self.directory = Path(config.get("directory", "~")).expanduser().resolve()
224
self.button = Gtk.MenuButton()
225
self.button.set_has_frame(False)
226
self.append(self.button)
227
228
self.inner_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
229
self.button.set_child(self.inner_box)
230
self.icon_name = config.get("icon_name")
231
self.show_hidden = config.get("show_hidden", False)
232
self.icon_size = config.get("icon_size", 24)
233
self.label = config.get("label")
234
self.icon = None
235
if config.get("icon_name"):
236
self.icon = Gtk.Image(icon_name=config["icon_name"], pixel_size=config.get("icon_size", 24))
237
self.inner_box.append(self.icon)
238
if config.get("label"):
239
self.inner_box.append(Gtk.Label.new(config["label"]))
240
241
self.button.set_always_show_arrow(True)
242
self.popover = Gtk.PopoverMenu(flags=Gtk.PopoverMenuFlags.NESTED, has_arrow=False, halign=Gtk.Align.START)
243
self.actions = {}
244
self.container_menu = Gio.Menu()
245
self.options_section = Gio.Menu()
246
open_directory = Gio.MenuItem()
247
open_directory.set_label(_("Open directory"))
248
open_directory.set_action_and_target_value("applet.open", GLib.Variant("s", str(self.directory)))
249
self.options_section.append_item(open_directory)
250
self.container_menu.append_section(None, self.options_section)
251
252
self.menu = FileMenu(self.directory, self.actions, self.new_action, self.delete_action, show_hidden=self.show_hidden)
253
self.container_menu.append_section(None, self.menu)
254
self.container_menu.file_list = self.menu
255
256
self.popover.set_menu_model(self.container_menu)
257
self.button.set_popover(self.popover)
258
panorama_panel.track_popover(self.popover)
259
260
self.open_action = Gio.SimpleAction(name="open", parameter_type=GLib.VariantType("s"))
261
self.open_action.connect("activate", self.open_file)
262
263
self.submenu_action = Gio.SimpleAction(name="submenu-status", state=GLib.Variant("b", False))
264
self.submenu_action.connect("notify", self.submenu_changed)
265
266
self.action_group.add_action(self.open_action)
267
self.action_group.add_action(self.submenu_action)
268
self.insert_action_group("applet", self.action_group)
269
270
self.popover.connect("show", lambda *args: self.menu.generate())
271
self.popover.connect("hide", lambda *args: self.menu.prune())
272
273
self.context_menu = self.make_context_menu()
274
panorama_panel.track_popover(self.context_menu)
275
276
right_click_controller = Gtk.GestureClick()
277
right_click_controller.set_button(3)
278
right_click_controller.connect("pressed", self.show_context_menu)
279
280
self.add_controller(right_click_controller)
281
282
options_action = Gio.SimpleAction.new("options", None)
283
options_action.connect("activate", self.show_options)
284
self.action_group.add_action(options_action)
285
286
def new_action(self, action_id):
287
action = Gio.SimpleAction(name=f"submenu-status-{action_id.hexdigest()}", state=GLib.Variant("b", False))
288
action.submenu = self.actions[action_id.hexdigest()]
289
action.connect("notify", self.submenu_changed)
290
self.action_group.add_action(action)
291
292
def delete_action(self, action_id):
293
self.action_group.remove(f"submenu-status-{action_id.hexdigest()}")
294
295
def open_file(self, action, data: GLib.Variant):
296
uri = GLib.filename_to_uri(data.get_string())
297
try:
298
GioUnix.DesktopAppInfo.new_from_filename(data.get_string()).launch()
299
except TypeError:
300
Gio.AppInfo.launch_default_for_uri(uri)
301
302
def submenu_changed(self, action, parameter):
303
if action.get_state():
304
action.submenu.generate()
305
else:
306
action.submenu.prune()
307
308
def make_context_menu(self):
309
menu = Gio.Menu()
310
menu.append(_("File menu _options"), "applet.options")
311
context_menu = Gtk.PopoverMenu(menu_model=menu, has_arrow=False, halign=Gtk.Align.START, flags=Gtk.PopoverMenuFlags.NESTED)
312
context_menu.set_parent(self)
313
return context_menu
314
315
def show_context_menu(self, gesture, n_presses, x, y):
316
rect = Gdk.Rectangle()
317
rect.x = int(x)
318
rect.y = int(y)
319
rect.width = 1
320
rect.height = 1
321
322
self.context_menu.set_pointing_to(rect)
323
self.context_menu.popup()
324
325
def update_icon_size(self, adjustment, *args):
326
self.icon_size = int(adjustment.get_value())
327
if self.icon is not None:
328
self.icon.set_pixel_size(self.icon_size)
329
self.emit("config-changed")
330
331
def show_options(self, _0=None, _1=None):
332
if self.options_window is None:
333
self.options_window = FileListingOptions()
334
# Setting the icon size
335
self.options_window.icon_size_adjustment.set_value(self.icon_size)
336
self.options_window.icon_size_adjustment.connect("value-changed", self.update_icon_size)
337
338
def reset_window(*args):
339
self.options_window = None
340
341
self.options_window.connect("close-request", reset_window)
342
# Setting the path
343
self.options_window.connect("new_path", self.set_path)
344
self.options_window.root_button.set_label(str(self.directory))
345
# Setting whether to show hidden files
346
self.options_window.hidden_button.set_active(self.show_hidden)
347
self.options_window.hidden_button.connect("toggled", self.set_show_hidden)
348
# Setting the icon name and label
349
self.options_window.icon_name_entry.connect("changed", self.update_button_content)
350
self.options_window.label_entry.connect("changed", self.update_button_content)
351
self.options_window.present()
352
353
def set_path(self, options_window, path):
354
self.menu.root = self.directory = Path(path)
355
self.emit("config-changed")
356
357
def update_button_content(self, *args):
358
if self.options_window:
359
self.icon = None
360
icon_name = self.options_window.icon_name_entry.get_text()
361
label = self.options_window.label_entry.get_text()
362
while self.inner_box.get_first_child():
363
self.inner_box.remove(self.inner_box.get_first_child())
364
if icon_name:
365
self.icon = Gtk.Image(icon_name=icon_name, pixel_size=self.icon_size)
366
self.inner_box.append(self.icon)
367
if label:
368
self.inner_box.append(Gtk.Label.new(label))
369
370
def set_show_hidden(self, checkbutton):
371
self.menu.show_hidden = self.show_hidden = checkbutton.get_active()
372
self.emit("config-changed")
373
374
def get_config(self):
375
return {
376
"directory": str(self.directory),
377
"icon_name": self.icon_name,
378
"icon_size": self.icon_size,
379
"label": self.label,
380
"show_hidden": self.show_hidden,
381
}
382
383
def set_panel_position(self, position):
384
self.button.set_direction(panorama_panel.POSITION_TO_ARROW[panorama_panel.OPPOSITE_POSITION[position]])
385