-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathactions.py
More file actions
575 lines (494 loc) · 21.8 KB
/
Copy pathactions.py
File metadata and controls
575 lines (494 loc) · 21.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
"""Built-in actions used by the default plugins."""
from __future__ import annotations
import os
import shlex
import shutil
import subprocess
import threading
import urllib.parse
from typing import Optional
from classifier import _normalize as _strip_invisible
# Thread-local 'force copy only' flag. When set, replace_selection
# writes to the clipboard but skips the Ctrl+V step - the popup uses
# this to honour Shift-click on a transform action: result lands on the
# clipboard for manual paste elsewhere, never overwrites the original
# selection. Mirrors PopClip's Shift-modifier behaviour where Shift
# forces a copy instead of a paste.
_local = threading.local()
def force_copy_active() -> bool:
return bool(getattr(_local, "force_copy", False))
class force_copy_mode:
"""Context manager: while active, replace_selection skips the paste
step and leaves the result on the clipboard only. Thread-local so
concurrent plugin executions don't interfere."""
def __enter__(self) -> None:
_local.force_copy = True
def __exit__(self, *_exc) -> None:
_local.force_copy = False
def pin_as_snippet(text: str) -> None:
"""One-click "save this selection as a snippet" - opens a small
create-snippet dialog with the highlighted text pre-filled as the
body. Delegates the actual save to the clipboard_history plugin
(looked up via sys.modules so we don't import a user-plugin
directly). Falls back to a notification if the plugin isn't
loaded."""
import sys
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import GLib, Gtk
ch = (sys.modules.get("linuxpop_user_clipboard_history")
or sys.modules.get("clipboard_history"))
if ch is None or not hasattr(ch, "_create_snippet"):
subprocess.run(
["notify-send", "--hint=byte:transient:1", "-t", "3000",
"-i", "dialog-warning", "LinuxPop",
"Snippets need the Clipboard history plugin enabled."],
check=False,
)
return
def _open():
dlg = Gtk.Dialog(title="Pin as snippet", flags=0)
dlg.add_buttons("Cancel", Gtk.ResponseType.CANCEL,
"Save", Gtk.ResponseType.OK)
dlg.set_default_response(Gtk.ResponseType.OK)
dlg.set_default_size(440, 0)
dlg.set_icon_name("linuxpop")
content = dlg.get_content_area()
content.set_spacing(6)
content.set_margin_top(12)
content.set_margin_bottom(12)
content.set_margin_start(14)
content.set_margin_end(14)
# Preview of the selection so the user knows what they're saving.
preview = text[:160].replace("\n", " ")
if len(text) > 160:
preview += "..."
prev_lbl = Gtk.Label(xalign=0)
prev_lbl.set_markup(
f"<small>Saving: <i>{GLib.markup_escape_text(preview)}</i></small>")
prev_lbl.set_line_wrap(True)
prev_lbl.get_style_context().add_class("dim-label")
content.add(prev_lbl)
name_lbl = Gtk.Label(xalign=0, margin_top=4)
name_lbl.set_markup("<b>Name</b>")
content.add(name_lbl)
name_entry = Gtk.Entry()
name_entry.set_placeholder_text("Short label so you'll recognise it")
name_entry.set_activates_default(True)
content.add(name_entry)
trig_lbl = Gtk.Label(xalign=0, margin_top=4)
trig_lbl.set_markup(
"<b>Trigger</b> <small>(optional)</small>")
content.add(trig_lbl)
trig_entry = Gtk.Entry()
trig_entry.set_placeholder_text("e.g. ;mysnippet")
trig_entry.set_activates_default(True)
content.add(trig_entry)
cat_lbl = Gtk.Label(xalign=0, margin_top=4)
cat_lbl.set_markup(
"<b>Category</b> <small>(optional)</small>")
content.add(cat_lbl)
cat_entry = Gtk.Entry()
cat_entry.set_placeholder_text("e.g. Email, Code, Personal")
cat_entry.set_activates_default(True)
content.add(cat_entry)
dlg.show_all()
name_entry.grab_focus()
response = dlg.run()
name = name_entry.get_text().strip()
trig = trig_entry.get_text().strip()
category = cat_entry.get_text().strip()
dlg.destroy()
if response != Gtk.ResponseType.OK:
return
try:
ch._create_snippet(
name=name, text=text, trigger=trig, category=category)
subprocess.run(
["notify-send", "--hint=byte:transient:1", "-t", "2000",
"-i", "linuxpop", "LinuxPop",
f"Saved snippet: {name or text[:40]}"],
check=False,
)
except Exception as exc:
print(f"[actions] pin_as_snippet save failed: {exc}")
GLib.idle_add(_open)
def copy_to_clipboard(text: str) -> None:
"""Copy text to the system clipboard (backend: xclip on X11, wl-copy on
Wayland/KDE)."""
from platform_backend import get_backend
get_backend().set_clipboard(text)
print(f"[actions] copied {len(text)} chars to clipboard")
def replace_selection(new_text: str) -> None:
"""Put new_text on the clipboard AND paste it over the current
selection - the in-place transform behaviour PopClip has on macOS.
Sequence:
1. write the new text to the clipboard
2. ~50 ms settle so the selection-owner change propagates
3. send Ctrl+V (backend), which the focused app interprets as
'paste over the current selection'
If the focused widget is read-only the paste is silently discarded,
but the result is still on the clipboard so the user can paste it
elsewhere - same fallback as a manual Copy.
"""
from platform_backend import get_backend
backend = get_backend()
backend.set_clipboard(new_text)
if force_copy_active():
# Shift-modifier on the popup button: result goes to the
# clipboard, but we deliberately skip the paste so the original
# selection stays put.
print("[actions] force-copy mode - clipboard only, no paste")
return
import time as _t
_t.sleep(0.05)
backend.paste()
if not backend.can_paste():
# No keystroke injector (e.g. the Flatpak sandbox on Wayland has no
# ydotool/wtype): the result is on the clipboard but wasn't pasted in
# place. Tell the user so they aren't left wondering why nothing changed.
subprocess.run(
["notify-send", "--hint=byte:transient:1", "-t", "3000",
"-i", "edit-paste", "Copied to clipboard",
"Auto-paste isn't available here - press Ctrl+V to paste."],
check=False,
)
def _desktop_env() -> dict:
"""Env that lets xdg-open talk to the running desktop session.
If LinuxPop was launched without inheriting DBUS_SESSION_BUS_ADDRESS
(some systemd-user / detached-shell scenarios), Firefox's
"is there an instance running?" probe fails - it can't find the
real process via DBus, sees the profile lockfile, and pops
"already running but not responding" every time. Filling in the
canonical /run/user/$UID values is a cheap safety net.
"""
env = dict(os.environ)
uid = os.getuid()
runtime_dir = f"/run/user/{uid}"
if not env.get("XDG_RUNTIME_DIR"):
env["XDG_RUNTIME_DIR"] = runtime_dir
if not env.get("DBUS_SESSION_BUS_ADDRESS"):
env["DBUS_SESSION_BUS_ADDRESS"] = f"unix:path={runtime_dir}/bus"
if not env.get("DISPLAY"):
env["DISPLAY"] = ":0"
return env
def open_url(text: str) -> None:
url = _strip_invisible(text)
# Naked URLs (no scheme) get https:// so the browser routes them correctly
if not url.lower().startswith(("http://", "https://", "ftp://", "file://", "mailto:")):
url = "https://" + url
from platform_backend import get_backend
get_backend().open_url(url)
print(f"[actions] opened URL: {url}")
# General-purpose web search engines. The value is a URL template - '{q}'
# gets replaced with the URL-encoded selection. Add to the dict to extend.
#
# Site-specific destinations (Wikipedia, YouTube, MDN, Stack Overflow, etc.)
# live as their own popup buttons via the recipe system - see
# plugins_repo/recipes/. Keep this dict scoped to "general search".
SEARCH_ENGINES: dict[str, tuple[str, str]] = {
# key: (display name, URL template)
"google": ("Google", "https://www.google.com/search?q={q}"),
"duckduckgo": ("DuckDuckGo", "https://duckduckgo.com/?q={q}"),
"bing": ("Bing", "https://www.bing.com/search?q={q}"),
"brave": ("Brave Search", "https://search.brave.com/search?q={q}"),
"startpage": ("Startpage", "https://www.startpage.com/do/search?q={q}"),
"ecosia": ("Ecosia", "https://www.ecosia.org/search?q={q}"),
"kagi": ("Kagi", "https://kagi.com/search?q={q}"),
"qwant": ("Qwant", "https://www.qwant.com/?q={q}"),
"yandex": ("Yandex", "https://yandex.com/search/?text={q}"),
}
def _search_template() -> str:
"""Return the URL template for the user's chosen search engine.
Falls back to Google if the configured engine is unknown."""
try:
from settings import get_settings
s = get_settings()
engine = (s.get("search_engine") or "google").strip().lower()
if engine == "custom":
tmpl = (s.get("search_engine_custom_url") or "").strip()
if "{q}" in tmpl:
return tmpl
# Empty/invalid custom URL - fall through to default.
if engine in SEARCH_ENGINES:
return SEARCH_ENGINES[engine][1]
except Exception:
pass
return SEARCH_ENGINES["google"][1]
def search_web(text: str) -> None:
query = urllib.parse.quote_plus(_strip_invisible(text))
url = _search_template().replace("{q}", query)
from platform_backend import get_backend
get_backend().open_url(url)
def _in_flatpak() -> bool:
return os.path.exists("/.flatpak-info")
_TERMINALS = [
("gnome-terminal", ["gnome-terminal", "--", "bash", "-c"]),
("konsole", ["konsole", "-e", "bash", "-c"]),
("xfce4-terminal", ["xfce4-terminal", "-e"]),
("alacritty", ["alacritty", "-e", "bash", "-c"]),
("kitty", ["kitty", "bash", "-c"]),
("x-terminal-emulator", ["x-terminal-emulator", "-e", "bash", "-c"]),
("xterm", ["xterm", "-hold", "-e", "bash", "-c"]), # xterm has -hold
]
def _host_has(binary: str) -> bool:
"""Is `binary` on the host's PATH? Checked through flatpak-spawn so it
works from inside the sandbox."""
try:
r = subprocess.run(
["flatpak-spawn", "--host", "sh", "-c",
"command -v %s" % shlex.quote(binary)],
capture_output=True, timeout=5,
)
return r.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def _find_terminal() -> Optional[tuple[str, list[str]]]:
"""Return (binary, argv-prefix) for the first available terminal emulator.
Inside Flatpak the command has to run on the host (running it in the
sandbox would hit the wrong filesystem and tools), so we look for the
terminal on the host's PATH and prefix the argv with flatpak-spawn --host."""
if _in_flatpak():
for binary, argv in _TERMINALS:
if _host_has(binary):
return binary, ["flatpak-spawn", "--host", *argv]
return None
for binary, argv in _TERMINALS:
if shutil.which(binary):
return binary, argv
return None
def _terminal_keep_open() -> bool:
try:
from settings import get_settings
return bool(get_settings().get("terminal_keep_open", True))
except Exception:
return True
def _should_confirm_terminal() -> bool:
try:
from settings import get_settings
return bool(get_settings().get("terminal_confirm_run", True))
except Exception:
return True
def _spawn_terminal(argv: list[str], log) -> None:
"""Launch a terminal emulator with hardening that makes the D-Bus
flavours (gnome-terminal, konsole) actually open a window:
* stdin=/dev/null -- the daemon's stdin is a socket, and inheriting
that into the gnome-terminal CLI confuses its D-Bus handshake.
* env=os.environ.copy() -- the posix_spawn fast path in Python 3.12
with env=None has been observed to drop some env vars that the
terminal's D-Bus client needs (DBUS_SESSION_BUS_ADDRESS, GIO*).
Explicit copy forces the fork+exec path.
* start_new_session=True -- terminal outlives LinuxPop if we exit.
"""
try:
subprocess.Popen(
argv,
stdin=subprocess.DEVNULL,
env=os.environ.copy(),
start_new_session=True,
)
except OSError as exc:
log.error("[terminal] launch failed: %s", exc)
raise
def _wrap_for_terminal(cmd: str) -> str:
"""Wrap a user command so the terminal echoes it first (looks like the
user pasted it), then runs it, and optionally drops into an interactive
shell so output stays visible."""
echoed = shlex.quote(f"\033[1;32m$\033[0m {cmd}")
if _terminal_keep_open():
return f"printf '%s\\n' {echoed}; {cmd}; exec bash"
return f"printf '%s\\n' {echoed}; {cmd}"
def _confirm_run_then_launch(cmd: str, binary: str, prefix: list[str]) -> bool:
"""GTK confirmation dialog. Default state is read-only preview --
Cancel / Edit / Run. Clicking Edit unlocks the command field for
in-place tweaking. Returns False so GLib.idle_add doesn't re-fire."""
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, Gtk
dlg = Gtk.Dialog(title="Run in terminal", modal=True)
dlg.set_default_size(580, 260)
dlg.set_icon_name("linuxpop")
dlg.set_keep_above(True)
dlg.set_position(Gtk.WindowPosition.CENTER)
content = dlg.get_content_area()
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10,
margin_top=14, margin_bottom=12,
margin_start=18, margin_end=18)
head = Gtk.Label(xalign=0)
head.set_markup("<span size='large' weight='bold'>Run this command?</span>")
box.pack_start(head, False, False, 0)
explain = Gtk.Label(xalign=0)
explain.set_markup(
"<span foreground='#8a92a8' size='small'>"
"Press <b>Run</b> to launch it as-is, <b>Edit</b> to tweak it first."
"</span>")
explain.set_line_wrap(True)
box.pack_start(explain, False, False, 0)
# Command preview/editor. Monospace TextView holds any character
# (including '&', '<', '>', shell metacharacters) without the
# Pango-markup ambiguity that the old MessageDialog had.
#
# Two visual states, toggled by the Edit button:
# .lp-cmd-preview -> read-only, flat on the dialog background,
# looks like a label (the "old preview" look)
# .lp-cmd-edit -> editable, picks up the input-field styling
# (border + slightly lighter bg + caret)
scroll = Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scroll.set_min_content_height(96)
# No shadow frame in the read-only state -- the IN shadow gives the
# white-box look the user disliked. Re-enabled on entering edit mode.
scroll.set_shadow_type(Gtk.ShadowType.NONE)
text_view = Gtk.TextView()
text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
try:
text_view.set_monospace(True) # GTK 3.16+
except AttributeError:
text_view.override_font(__import__("gi").repository.Pango.FontDescription("monospace 11"))
text_view.set_editable(False)
text_view.set_cursor_visible(False)
text_view.get_style_context().add_class("lp-cmd-preview")
text_buf = text_view.get_buffer()
text_buf.set_text(cmd)
scroll.add(text_view)
box.pack_start(scroll, True, True, 0)
# Button row: Cancel · Edit · Run
btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8,
margin_top=4)
btn_box.pack_start(Gtk.Label(), True, True, 0)
cancel = Gtk.Button(label="Cancel")
cancel.connect("clicked", lambda *_: dlg.response(Gtk.ResponseType.CANCEL))
btn_box.pack_start(cancel, False, False, 0)
edit_btn = Gtk.Button(label="Edit")
edit_btn.set_tooltip_text("Unlock the command for editing")
def _enter_edit_mode(*_):
text_view.set_editable(True)
text_view.set_cursor_visible(True)
# Swap visual state: drop the flat preview look, pick up the
# input-field look (border + slight bg + caret).
ctx = text_view.get_style_context()
ctx.remove_class("lp-cmd-preview")
ctx.add_class("lp-cmd-edit")
scroll.set_shadow_type(Gtk.ShadowType.IN)
text_view.grab_focus()
# Select all so the user can just start typing to replace, or
# press End/Arrow to position the caret to tweak.
text_buf.select_range(text_buf.get_start_iter(),
text_buf.get_end_iter())
# The button has done its job - disable so it visually confirms
# "you're in edit mode now".
edit_btn.set_sensitive(False)
edit_btn.set_label("Editing…")
explain.set_markup(
"<span foreground='#8a92a8' size='small'>"
"<b>Editing</b> -- Ctrl+Enter to run, Esc to cancel."
"</span>")
edit_btn.connect("clicked", _enter_edit_mode)
btn_box.pack_start(edit_btn, False, False, 0)
run_btn = Gtk.Button(label="Run")
run_btn.get_style_context().add_class("suggested-action")
run_btn.connect("clicked", lambda *_: dlg.response(Gtk.ResponseType.OK))
btn_box.pack_start(run_btn, False, False, 0)
box.pack_start(btn_box, False, False, 0)
# Keyboard shortcuts. Two separate handlers because GTK key events
# are dispatched to the focused widget FIRST -- if GtkTextView's
# default handler consumes Enter (to insert a newline), the dialog's
# key-press-event never fires. So Ctrl+Enter has to be intercepted
# ON the TextView itself, before the default handler runs.
def _on_textview_key(_w, event):
is_enter = event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter)
ctrl = bool(event.state & Gdk.ModifierType.CONTROL_MASK)
if is_enter and ctrl:
dlg.response(Gtk.ResponseType.OK)
return True # consume, don't insert a newline
return False # let TextView handle plain Enter etc. normally
text_view.connect("key-press-event", _on_textview_key)
def _on_dialog_key(_w, event):
is_enter = event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter)
ctrl = bool(event.state & Gdk.ModifierType.CONTROL_MASK)
if event.keyval == Gdk.KEY_Escape:
dlg.response(Gtk.ResponseType.CANCEL)
return True
if is_enter and (ctrl or not text_view.get_editable()):
dlg.response(Gtk.ResponseType.OK)
return True
return False
dlg.connect("key-press-event", _on_dialog_key)
content.add(box)
dlg.show_all()
# Focus the Run button by default so plain Enter confirms the as-is
# command (the common path). Edit button is one Tab away.
run_btn.grab_focus()
response = dlg.run()
import logging
log = logging.getLogger("linuxpop")
if response == Gtk.ResponseType.OK:
start, end = text_buf.get_start_iter(), text_buf.get_end_iter()
edited = text_buf.get_text(start, end, True).strip()
if not edited:
log.info("[terminal] OK pressed but command was empty -- skipping")
else:
wrapped = _wrap_for_terminal(edited)
try:
_spawn_terminal([*prefix, wrapped], log)
log.info("[terminal] launched (%s): %s", binary, edited[:120])
except OSError:
pass # already logged inside _spawn_terminal
else:
log.info("[terminal] cancelled (response=%s)", response)
dlg.destroy()
return False
def run_in_terminal(text: str) -> None:
cmd = _strip_invisible(text)
found = _find_terminal()
if not found:
print("[actions] no terminal emulator found")
return
binary, prefix = found
if _should_confirm_terminal():
# Marshal to the GTK main thread for the modal dialog. Setting
# terminal_confirm_run=false in settings.json skips this prompt.
# The dialog rebuilds the wrapped command after the user edits.
from gi.repository import GLib
GLib.idle_add(_confirm_run_then_launch, cmd, binary, prefix)
return
import logging
log = logging.getLogger("linuxpop")
wrapped = _wrap_for_terminal(cmd)
try:
_spawn_terminal([*prefix, wrapped], log)
log.info("[terminal] launched (%s): %s", binary, cmd[:120])
except OSError:
pass # already logged
def open_path(text: str) -> None:
raw = _strip_invisible(text)
if _in_flatpak():
# Open on the host: the sandbox can't open an arbitrary host path
# (the OpenURI portal needs a readable fd we don't have). Expand a
# leading ~ against the HOST's $HOME, then run the host's xdg-open.
argv = ["flatpak-spawn", "--host", "sh", "-c",
'p="$1"; case "$p" in "~"*) p="$HOME${p#\\~}";; esac; '
'exec xdg-open "$p"', "sh", raw]
try:
subprocess.Popen(argv, start_new_session=True)
print(f"[actions] opened path on host: {raw}")
except OSError:
print("[actions] flatpak-spawn not available")
return
path = os.path.expanduser(raw)
try:
subprocess.Popen(
["xdg-open", path],
start_new_session=True,
env=_desktop_env(),
)
print(f"[actions] opened path: {path}")
except FileNotFoundError:
print("[actions] xdg-open not available")
def compose_email(text: str) -> None:
addr = _strip_invisible(text)
subprocess.Popen(
["xdg-open", f"mailto:{addr}"],
start_new_session=True,
env=_desktop_env(),
)