pluto-shelf 0.1.0__py3-none-any.whl
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.
- pluto/__init__.py +58 -0
- pluto/app.py +129 -0
- pluto/config.py +119 -0
- pluto/drawer.py +842 -0
- pluto/ingest.py +240 -0
- pluto/install.py +56 -0
- pluto/shake.py +130 -0
- pluto/store.py +243 -0
- pluto/style.py +266 -0
- pluto/tray.py +324 -0
- pluto_shelf-0.1.0.dist-info/METADATA +103 -0
- pluto_shelf-0.1.0.dist-info/RECORD +15 -0
- pluto_shelf-0.1.0.dist-info/WHEEL +4 -0
- pluto_shelf-0.1.0.dist-info/entry_points.txt +3 -0
- pluto_shelf-0.1.0.dist-info/licenses/LICENSE +21 -0
pluto/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""pluto — a drop shelf for Wayland."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _ensure_layer_shell_preloaded() -> None:
|
|
11
|
+
"""gtk4-layer-shell must interpose libwayland-client before GTK loads it. dlopen(RTLD_GLOBAL) from Python is not
|
|
12
|
+
reliable for that (some hooks are missed and the layer surface ends up in a resize loop), so re-exec with LD_PRELOAD."""
|
|
13
|
+
import ctypes.util
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
if os.environ.get("PLUTO_PRELOADED") == "1":
|
|
17
|
+
return
|
|
18
|
+
lib = ctypes.util.find_library("gtk4-layer-shell")
|
|
19
|
+
if not lib:
|
|
20
|
+
sys.stderr.write("pluto: gtk4-layer-shell is not installed (libgtk4-layer-shell.so not found)\n")
|
|
21
|
+
sys.exit(1)
|
|
22
|
+
env = dict(os.environ)
|
|
23
|
+
env["LD_PRELOAD"] = " ".join(filter(None, [lib, env.get("LD_PRELOAD")]))
|
|
24
|
+
env["PLUTO_PRELOADED"] = "1"
|
|
25
|
+
os.execve(sys.executable, [sys.executable, *sys.argv], env)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main() -> int:
|
|
29
|
+
args = sys.argv[1:]
|
|
30
|
+
if args[:1] == ["install"]:
|
|
31
|
+
from .install import run
|
|
32
|
+
|
|
33
|
+
return run(args[1:])
|
|
34
|
+
if args[:1] == ["config"]:
|
|
35
|
+
from .config import write_default
|
|
36
|
+
|
|
37
|
+
print(write_default())
|
|
38
|
+
return 0
|
|
39
|
+
if args[:1] in (["-h"], ["--help"], ["help"]):
|
|
40
|
+
from .app import USAGE
|
|
41
|
+
|
|
42
|
+
print(USAGE, end="")
|
|
43
|
+
return 0
|
|
44
|
+
if args[:1] in (["-V"], ["--version"]):
|
|
45
|
+
print(f"pluto {__version__}")
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
_ensure_layer_shell_preloaded()
|
|
49
|
+
import gi
|
|
50
|
+
|
|
51
|
+
gi.require_version("Gtk", "4.0")
|
|
52
|
+
gi.require_version("Gdk", "4.0")
|
|
53
|
+
gi.require_version("GdkPixbuf", "2.0")
|
|
54
|
+
gi.require_version("Gtk4LayerShell", "1.0")
|
|
55
|
+
|
|
56
|
+
from .app import ShelfApp
|
|
57
|
+
|
|
58
|
+
return ShelfApp().run(sys.argv)
|
pluto/app.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from gi.repository import Gio, GLib, Gtk
|
|
6
|
+
|
|
7
|
+
from . import config, ingest, style
|
|
8
|
+
from .config import APP_ID
|
|
9
|
+
from .drawer import Drawer
|
|
10
|
+
from .store import Item, Store
|
|
11
|
+
from .shake import ShakeDetector
|
|
12
|
+
from .tray import Tray
|
|
13
|
+
|
|
14
|
+
VERBS = ("toggle", "show", "hide", "new", "quit")
|
|
15
|
+
USAGE = """usage: pluto [command]
|
|
16
|
+
|
|
17
|
+
(none) start the daemon (or report that it is already running)
|
|
18
|
+
toggle open / close the drawer
|
|
19
|
+
show open the drawer
|
|
20
|
+
hide close the drawer
|
|
21
|
+
new start a new shelf (the current one is kept in the tray)
|
|
22
|
+
add ITEM... put files, folders, URLs or text on the active shelf
|
|
23
|
+
quit stop the daemon
|
|
24
|
+
install write Hyprland rules, keybind and autostart
|
|
25
|
+
config create ~/.config/pluto/config.toml with the defaults
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ShelfApp(Gtk.Application):
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
super().__init__(application_id=APP_ID, flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE)
|
|
32
|
+
self.cfg: config.Config | None = None
|
|
33
|
+
self.store: Store | None = None
|
|
34
|
+
self.drawer: Drawer | None = None
|
|
35
|
+
self.tray: Tray | None = None
|
|
36
|
+
self.shake: ShakeDetector | None = None
|
|
37
|
+
|
|
38
|
+
def do_startup(self) -> None:
|
|
39
|
+
Gtk.Application.do_startup(self)
|
|
40
|
+
self.cfg = config.load()
|
|
41
|
+
style.install(self.cfg)
|
|
42
|
+
self.store = Store()
|
|
43
|
+
self.drawer = Drawer(self, self.store, self.cfg)
|
|
44
|
+
self.tray = Tray(
|
|
45
|
+
self.store,
|
|
46
|
+
on_activate=self.drawer.toggle,
|
|
47
|
+
on_select_shelf=self._select_shelf,
|
|
48
|
+
on_new=self._new_shelf,
|
|
49
|
+
on_clear=self.store.clear,
|
|
50
|
+
on_delete=lambda: self.store.delete_shelf(self.store.active_id),
|
|
51
|
+
on_quit=self.quit_safely,
|
|
52
|
+
icon_color=self.cfg.palette.fg,
|
|
53
|
+
)
|
|
54
|
+
for verb in VERBS:
|
|
55
|
+
action = Gio.SimpleAction.new(verb, None)
|
|
56
|
+
action.connect("activate", self._on_verb, verb)
|
|
57
|
+
self.add_action(action)
|
|
58
|
+
self.drawer.present()
|
|
59
|
+
if self.cfg.shake:
|
|
60
|
+
self.shake = ShakeDetector(self._on_shake, window_ms=self.cfg.shake_window_ms, travel=self.cfg.shake_travel, reversals=self.cfg.shake_reversals, requires_grab=self.cfg.shake_requires_grab)
|
|
61
|
+
self.shake.start()
|
|
62
|
+
|
|
63
|
+
def _on_shake(self, x: float, y: float) -> bool:
|
|
64
|
+
if not self.drawer.expanded:
|
|
65
|
+
self.drawer.expand()
|
|
66
|
+
self.drawer._schedule_collapse(3500)
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def do_activate(self) -> None:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
def do_command_line(self, cmdline: Gio.ApplicationCommandLine) -> int:
|
|
73
|
+
args = cmdline.get_arguments()[1:]
|
|
74
|
+
if not args:
|
|
75
|
+
if cmdline.get_is_remote():
|
|
76
|
+
cmdline.printerr_literal("pluto: already running\n")
|
|
77
|
+
return 0
|
|
78
|
+
verb = args[0]
|
|
79
|
+
if verb in VERBS:
|
|
80
|
+
self.activate_action(verb, None)
|
|
81
|
+
return 0
|
|
82
|
+
if verb == "add":
|
|
83
|
+
cwd = cmdline.get_cwd() or "/"
|
|
84
|
+
items = [self._item_from_arg(arg, cwd) for arg in args[1:]]
|
|
85
|
+
self.drawer.add_items(items)
|
|
86
|
+
self.drawer.expand()
|
|
87
|
+
self.drawer._schedule_collapse(4000)
|
|
88
|
+
return 0
|
|
89
|
+
cmdline.printerr_literal(USAGE)
|
|
90
|
+
return 2
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _item_from_arg(arg: str, cwd: str) -> Item:
|
|
94
|
+
if ingest.URL_RE.match(arg):
|
|
95
|
+
return Item.link(arg)
|
|
96
|
+
path = arg if os.path.isabs(arg) else os.path.join(cwd, arg)
|
|
97
|
+
if os.path.exists(path):
|
|
98
|
+
return Item.file(os.path.normpath(path))
|
|
99
|
+
return Item.text_snippet(arg)
|
|
100
|
+
|
|
101
|
+
def _on_verb(self, action, param, verb: str) -> None:
|
|
102
|
+
if verb == "toggle":
|
|
103
|
+
self.drawer.toggle()
|
|
104
|
+
elif verb == "show":
|
|
105
|
+
self.drawer.expand()
|
|
106
|
+
elif verb == "hide":
|
|
107
|
+
self.drawer.collapse(force=True)
|
|
108
|
+
elif verb == "new":
|
|
109
|
+
self._new_shelf()
|
|
110
|
+
elif verb == "quit":
|
|
111
|
+
self.quit_safely()
|
|
112
|
+
|
|
113
|
+
def _select_shelf(self, shelf_id: str) -> None:
|
|
114
|
+
self.store.set_active(shelf_id)
|
|
115
|
+
self.drawer.expand()
|
|
116
|
+
self.drawer._schedule_collapse(4000)
|
|
117
|
+
|
|
118
|
+
def _new_shelf(self) -> None:
|
|
119
|
+
self.store.new_shelf()
|
|
120
|
+
self.drawer.expand()
|
|
121
|
+
self.drawer._schedule_collapse(4000)
|
|
122
|
+
|
|
123
|
+
def quit_safely(self) -> None:
|
|
124
|
+
# Quitting mid-drag leaves the compositor's drag grab dangling; wait for it to end.
|
|
125
|
+
if self.drawer.dnd_active or self.drawer.drag_out_active:
|
|
126
|
+
GLib.timeout_add(500, lambda: (self.quit_safely(), False)[1])
|
|
127
|
+
return
|
|
128
|
+
self.store.save_now()
|
|
129
|
+
self.quit()
|
pluto/config.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import dataclass, field, fields
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
XDG_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
9
|
+
XDG_DATA = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
|
10
|
+
|
|
11
|
+
CONFIG_DIR = XDG_CONFIG / "pluto"
|
|
12
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
13
|
+
DATA_DIR = XDG_DATA / "pluto"
|
|
14
|
+
STATE_FILE = DATA_DIR / "state.json"
|
|
15
|
+
BLOB_DIR = DATA_DIR / "blobs"
|
|
16
|
+
BUTTON_FILE = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "pluto-button"
|
|
17
|
+
|
|
18
|
+
APP_ID = "io.pluto.Pluto"
|
|
19
|
+
NAMESPACE = "pluto"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Palette:
|
|
24
|
+
bg: str = "#1e1e2e"
|
|
25
|
+
surface: str = "#313244"
|
|
26
|
+
fg: str = "#cdd6f4"
|
|
27
|
+
muted: str = "#6c7086"
|
|
28
|
+
dim: str = "#585b70"
|
|
29
|
+
accent: str = "#89b4fa"
|
|
30
|
+
danger: str = "#f38ba8"
|
|
31
|
+
border: str = "#45475a"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Config:
|
|
36
|
+
edge: str = "right"
|
|
37
|
+
width: int = 320
|
|
38
|
+
max_height: float = 0.7
|
|
39
|
+
strip_width: int = 3
|
|
40
|
+
opacity: float = 0.8
|
|
41
|
+
radius: int = 6
|
|
42
|
+
font: str = "JetBrainsMono Nerd Font, JetBrains Mono, monospace"
|
|
43
|
+
font_size: int = 12
|
|
44
|
+
auto_collapse_ms: int = 1200
|
|
45
|
+
linger_after_drop_ms: int = 2500
|
|
46
|
+
remove_on_drag_out: bool = False
|
|
47
|
+
drag_out_action: str = "copy"
|
|
48
|
+
slide_ms: int = 180
|
|
49
|
+
shake: bool = True
|
|
50
|
+
shake_requires_grab: bool = True
|
|
51
|
+
shake_reversals: int = 4
|
|
52
|
+
shake_travel: int = 40
|
|
53
|
+
shake_window_ms: int = 600
|
|
54
|
+
palette: Palette = field(default_factory=Palette)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
DEFAULT_CONFIG_TOML = """\
|
|
58
|
+
# pluto configuration — every key is optional; these are the defaults.
|
|
59
|
+
|
|
60
|
+
edge = "right" # screen edge the drawer lives on: "right" or "left"
|
|
61
|
+
width = 320 # drawer width in logical pixels
|
|
62
|
+
max_height = 0.7 # drawer grows with content up to this fraction of the screen
|
|
63
|
+
strip_width = 3 # width of the invisible edge strip that catches drags
|
|
64
|
+
opacity = 0.8 # panel background alpha (pairs with Hyprland blur)
|
|
65
|
+
radius = 6 # corner radius on the screen-facing side
|
|
66
|
+
font = "JetBrainsMono Nerd Font, JetBrains Mono, monospace"
|
|
67
|
+
font_size = 12
|
|
68
|
+
auto_collapse_ms = 1200 # collapse this long after the pointer leaves
|
|
69
|
+
linger_after_drop_ms = 2500
|
|
70
|
+
remove_on_drag_out = false # remove an item from the shelf after dragging it out (Ctrl-drag keeps it)
|
|
71
|
+
slide_ms = 180 # open/close animation duration
|
|
72
|
+
drag_out_action = "copy" # "copy": targets always copy; "move": the target may move the file (shelf item goes stale)
|
|
73
|
+
shake = true # shake the pointer left-right to summon the panel (Hyprland only)
|
|
74
|
+
shake_requires_grab = true # only while the left button is held (needs the binds from `pluto install`)
|
|
75
|
+
shake_reversals = 4 # direction changes needed within shake_window_ms
|
|
76
|
+
shake_travel = 40 # minimum pixels per leg of the shake
|
|
77
|
+
shake_window_ms = 600
|
|
78
|
+
|
|
79
|
+
[palette]
|
|
80
|
+
bg = "#1e1e2e"
|
|
81
|
+
surface = "#313244"
|
|
82
|
+
fg = "#cdd6f4"
|
|
83
|
+
muted = "#6c7086"
|
|
84
|
+
dim = "#585b70"
|
|
85
|
+
accent = "#89b4fa"
|
|
86
|
+
danger = "#f38ba8"
|
|
87
|
+
border = "#45475a"
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _apply(obj, data: dict) -> None:
|
|
92
|
+
names = {f.name: f.type for f in fields(obj)}
|
|
93
|
+
for key, value in data.items():
|
|
94
|
+
if key in names and key != "palette":
|
|
95
|
+
setattr(obj, key, value)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load() -> Config:
|
|
99
|
+
cfg = Config()
|
|
100
|
+
if not CONFIG_FILE.exists():
|
|
101
|
+
return cfg
|
|
102
|
+
with CONFIG_FILE.open("rb") as fh:
|
|
103
|
+
data = tomllib.load(fh)
|
|
104
|
+
_apply(cfg, data)
|
|
105
|
+
_apply(cfg.palette, data.get("palette", {}))
|
|
106
|
+
if cfg.edge not in ("right", "left"):
|
|
107
|
+
cfg.edge = "right"
|
|
108
|
+
if cfg.drag_out_action not in ("copy", "move"):
|
|
109
|
+
cfg.drag_out_action = "copy"
|
|
110
|
+
cfg.strip_width = max(1, int(cfg.strip_width))
|
|
111
|
+
cfg.opacity = min(1.0, max(0.0, float(cfg.opacity)))
|
|
112
|
+
return cfg
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def write_default() -> Path:
|
|
116
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
if not CONFIG_FILE.exists():
|
|
118
|
+
CONFIG_FILE.write_text(DEFAULT_CONFIG_TOML)
|
|
119
|
+
return CONFIG_FILE
|