diff --git a/README.md b/README.md index d8a9257..c284149 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # LinuxPop -> Like PopClip on the Mac, but for Linux. +> Like PopClip on the Mac, but for Linux. Highlight text, get a popup of actions. Works on X11 and KDE Plasma 6 Wayland. ![LinuxPop in action: select text, a popup of context actions appears, here translating the selection](docs/linuxpop-demo.gif) @@ -169,6 +169,23 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full plugin API. --- +## FAQ + +**Is there a PopClip for Linux?** +That's pretty much what LinuxPop is. Highlight some text and a popup of actions shows up over it. It's free, MIT-licensed, and runs on X11 and KDE Plasma 6 Wayland. + +**Does it work on Wayland?** +On KDE Plasma 6, yes: selection goes through `wl-clipboard`, the popup is placed with `gtk-layer-shell`, and the cursor position comes from KWin's scripting API. Other Wayland compositors fall back to the X11 path under XWayland. + +**Which desktops are supported?** +Cinnamon, GNOME-on-X11, KDE, XFCE and MATE on X11, plus KDE Plasma 6 on Wayland. + +**Does it send my text anywhere?** +No, not unless a plugin clearly does (the "Send to Claude" button being the obvious one). Otherwise everything stays on your machine. No telemetry, no accounts. + +**How do I uninstall it?** +`bash ~/linuxpop/install.sh --uninstall` for a source install, or remove the Flatpak or native package the usual way. + ## License MIT. See [LICENSE](LICENSE). diff --git a/docs/assets/style.css b/docs/assets/style.css index cc5c178..2699f27 100644 --- a/docs/assets/style.css +++ b/docs/assets/style.css @@ -251,18 +251,6 @@ section.tight { padding: 32px 0; } transform: translateY(-2px); border-color: var(--border-strong); } -.feature-icon { - width: 36px; - height: 36px; - border-radius: 9px; - background: var(--gradient); - display: inline-flex; - align-items: center; - justify-content: center; - margin-bottom: 14px; - color: white; - font-size: 18px; -} .feature h3 { margin-top: 0; font-size: 1.1rem; } .feature p { color: var(--text-dim); margin: 0; font-size: 0.94rem; } diff --git a/docs/design/_gen_grouped_mock.py b/docs/design/_gen_grouped_mock.py new file mode 100644 index 0000000..a631d61 --- /dev/null +++ b/docs/design/_gen_grouped_mock.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Mock of the category-grouped popup (collapsed vs expanded), built from the +real symbolic icon files so it reflects what ships.""" +import os +import cairosvg + +HERE = os.path.dirname(os.path.abspath(__file__)) +ICONS = os.path.normpath(os.path.join(HERE, "..", "..", "icons")) + + +def icon_inner(name): + svg = open(os.path.join(ICONS, name + ".svg")).read() + return svg[svg.index(">", svg.index("")] + + +def place(name, x, y, s=34): + return f'{icon_inner(name)}' + + +W, H = 1180, 720 +BG, PANEL, RULE = "#0E1016", "#161922", "#262B38" +INK, DIM, ACC = "#E8EAF2", "#6B7184", "#8AA0FF" +THIN = 'font-family="Helvetica Neue,Arial,sans-serif" font-weight="200"' +REG = 'font-family="Helvetica Neue,Arial,sans-serif" font-weight="400"' + +p = [f''] +p.append(f'Category grouping') +p.append(f'FORMATTING & MARKDOWN COLLAPSE BEHIND A CHIP · CLICK TO EXPAND') +p.append(f'') + + +def pill(x, y, w, h): + return (f'') + + +g = 44 # cell pitch + +# --- collapsed ----------------------------------------------------------- +p.append(f'COLLAPSED — what you see first') +row = ["linuxpop-clipboard-symbolic", "linuxpop-format-symbolic", "linuxpop-md-symbolic", "linuxpop-dictionary-symbolic"] +bx, by, bh = 80, 256, 64 +bw = g * len(row) + 24 +p.append(pill(bx, by, bw, bh)) +x = bx + 16 +for name in row: + p.append(place(name, x, by + 15)) + x += g +p.append(f'copy · ') +p.append(f'A▾ Formatting') +p.append(f'.md▾ Markdown') + +# --- expanded ------------------------------------------------------------ +p.append(f'EXPANDED — after clicking the Markdown chip') +ex, ey = 80, 424 +row2 = ["linuxpop-md-bold-symbolic", "linuxpop-md-italic-symbolic", "linuxpop-md-strikethrough-symbolic", + "linuxpop-md-highlight-symbolic", "linuxpop-md-quote-symbolic", "linuxpop-md-code-symbolic", + "linuxpop-md-link-symbolic"] +ew = max(g * len(row) + 24, g * len(row2) + 24) +eh = 64 * 2 + 6 +p.append(f'') +# row 1 (collapsed view, markdown chip now "active") +x = ex + 16 +for name in row: + box = '' + if name == "linuxpop-md-symbolic": + box = f'' + p.append(box + place(name, x, ey + 15)) + x += g +# divider +p.append(f'') +# row 2 (members) +x = ex + 16 +for name in row2: + p.append(place(name, x, ey + 78)) + x += g +p.append(f'the 7 markdown') +p.append(f'actions reveal on') +p.append(f'the second row') + +p.append(f'') +p.append(f'popup_group_categories · the red .md tag marks every markdown action') + +svg = f'' + "".join(p) + "" +out = os.path.join(HERE, "category-grouping-mock.png") +cairosvg.svg2png(bytestring=svg.encode(), write_to=out, output_width=W * 2, output_height=H * 2) +print("wrote", out) diff --git a/docs/design/_gen_markdown_icons.py b/docs/design/_gen_markdown_icons.py new file mode 100644 index 0000000..e7dd182 --- /dev/null +++ b/docs/design/_gen_markdown_icons.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Generate the LinuxPop text-formatting icon set. + +Two families, one house style (48x48, rounded indigo plate + hairline bezel +for colour; bare #f0f0f0 glyph for symbolic): + + linuxpop-format-* rich-text formatting (Ctrl+B/I/U). The styled letter. + linuxpop-md-* markdown wrapping (**, ==, > ...). Same base glyph PLUS + a small monospace corner badge of the literal syntax it + inserts, so the user can tell the two mechanisms apart. + +Renders a comparison specimen PNG via _gen_specimen.py separately. +""" +import glob +import os + +HERE = os.path.dirname(os.path.abspath(__file__)) +ICONS = os.path.normpath(os.path.join(HERE, "..", "..", "icons")) + +# --- house style ---------------------------------------------------------- +TOP, BOT = "#6E8BF5", "#3F3AD0" # formatting family gradient (indigo) +AMBER = "#FFD773" # the one sanctioned warm accent +GLYPH_SYM = "#f0f0f0" # symbolic ink (white-on-dark popup) +GLYPH_COL = "#ffffff" # ink on the colour plate + +COLOR_HEAD = ( + '' + '' + '' +) +SYM_HEAD = ('') +TAIL = "" + + +def _esc(s): + return s.replace("&", "&").replace("<", "<").replace(">", ">") + + +# --- glyphs (c = ink colour; amber = highlight accent) -------------------- +def g_bold(c, _a): + return (f'B') + + +def g_italic(c, _a): + return (f'I') + + +def g_underline(c, _a): + return (f'U' + f'') + + +def g_strike(c, _a): + return (f'S' + f'') + + +def g_highlight(c, amber): + if amber: + band = f'' + else: + band = (f'') + return band + (f'') + + +def g_quote(c, _a): + return (f'' + f'' + f'' + f'') + + +def g_code(c, _a): + return (f'' + f'' + f'') + + +def g_link(c, _a): + ring = ('') + return (f'{ring}' + f'{ring}') + + +# --- category-chip glyphs ------------------------------------------------- +# Each category carries its own signature accent so it's recognisable even at +# 16px: teal for Formatting, red for Markdown. Kept clean (no caret) for small +# legibility; the chip's tooltip and expand behaviour signal that it opens. +ACCENT_FORMAT = "#46C8B8" + + +def _page(c): + return (f'' + f'') + + +def g_cat_format(c, _a): + # the word-processor "A" over a teal format bar (the classic font-format mark) + return (f'A' + f'') + + +def g_cat_md(c, _a): + # a document with the red ".md" extension tag -- "a markdown file" + tag = (f'' + f'.md') + return _page(c) + tag + + +# --- corner earmark: a small red ".md" tag ------------------------------- +RED = "#E5484D" + + +def badge(_token, _is_color): + """A small red ".md" pill in the bottom-right. + + Uniform across the whole md family. The base glyph says *which* action; + this red file-extension tag says, unmistakably, "markdown". Drawn the same + on the symbolic and colour versions so it reads identically in the popup + row and in the catalogue. + """ + bw, bh = 21, 13 + bx, by = 44 - bw, 45 - bh # bottom-right, inside the bezel + pill = (f'' + f'') + txt = (f'.md') + return f'{pill}{txt}' + + +# (key, label, glyph fn) -- rich-text formatting, NO badge +FORMAT_DEF = [ + ("bold", "Bold", g_bold), + ("italic", "Italic", g_italic), + ("underline", "Underline", g_underline), +] + +# (key, label, syntax token, glyph fn) -- markdown, WITH corner badge +MD_DEF = [ + ("bold", "Bold", "**", g_bold), + ("italic", "Italic", "*", g_italic), + ("strikethrough", "Strikethrough", "~~", g_strike), + ("highlight", "Highlight", "==", g_highlight), + ("quote", "Quote", ">", g_quote), + ("code", "Code", "`", g_code), + ("link", "Link", "[]", g_link), +] + + +def _write(name, inner_sym, inner_col): + with open(os.path.join(ICONS, f"{name}-symbolic.svg"), "w") as f: + f.write(SYM_HEAD + inner_sym + TAIL) + with open(os.path.join(ICONS, f"{name}.svg"), "w") as f: + f.write(COLOR_HEAD + inner_col + TAIL) + + +def write_all(): + # clean any earlier generation so renames don't leave stale files + for f in glob.glob(os.path.join(ICONS, "linuxpop-md-*.svg")) + \ + glob.glob(os.path.join(ICONS, "linuxpop-format-*.svg")): + os.remove(f) + + for key, _label, fn in FORMAT_DEF: + _write(f"linuxpop-format-{key}", fn(GLYPH_SYM, False), fn(GLYPH_COL, True)) + + for key, _label, token, fn in MD_DEF: + sym = fn(GLYPH_SYM, False) + badge(token, False) + col = fn(GLYPH_COL, True) + badge(token, True) + _write(f"linuxpop-md-{key}", sym, col) + + # category-chip icons (no per-item badge) + for key, fn in (("format", g_cat_format), ("md", g_cat_md)): + _write(f"linuxpop-{key}", fn(GLYPH_SYM, False), fn(GLYPH_COL, True)) + + n = (len(FORMAT_DEF) + len(MD_DEF)) * 2 + 4 + print(f"wrote {n} icons to {ICONS}") + + +if __name__ == "__main__": + write_all() diff --git a/docs/design/_gen_specimen.py b/docs/design/_gen_specimen.py new file mode 100644 index 0000000..8c56892 --- /dev/null +++ b/docs/design/_gen_specimen.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Render the Indigo Syntax specimen sheet: the two formatting families. + +Rich-text (linuxpop-format-*) keeps the plain letterforms; markdown +(linuxpop-md-*) is the same base glyph plus a red ".md" earmark. Shown as +colour plates, popup glyphs, and a real-size popup row. +""" +import importlib.util +import os + +import cairosvg + +HERE = os.path.dirname(os.path.abspath(__file__)) +spec = importlib.util.spec_from_file_location( + "mdicons", os.path.join(HERE, "_gen_markdown_icons.py")) +M = importlib.util.module_from_spec(spec) +spec.loader.exec_module(M) + +SYM = M.GLYPH_SYM +W, H = 1480, 1060 +BG, PANEL, RULE = "#0E1016", "#161922", "#262B38" +INK, DIM, ACC, RED = "#E8EAF2", "#6B7184", "#8AA0FF", "#E5484D" +THIN = 'font-family="Helvetica Neue,Arial,sans-serif" font-weight="200"' +REG = 'font-family="Helvetica Neue,Arial,sans-serif" font-weight="400"' +MONO = 'font-family="DejaVu Sans Mono,monospace"' + + +def place_color(glyph, earmark, x, y, s, uid): + inner = M.COLOR_HEAD[M.COLOR_HEAD.index(">") + 1:] + glyph + earmark + inner = inner.replace('id="g"', f'id="g{uid}"').replace("url(#g)", f"url(#g{uid})") + return f'{inner}' + + +def place_sym(glyph, earmark, x, y, s): + return f'{glyph}{earmark}' + + +def sym_swatch(glyph, earmark, cx, cy, tile=52, gs=48): + return (f'' + + place_sym(glyph, earmark, cx - gs/2, cy + (tile-gs)/2, gs)) + + +p = [f''] + +# --- masthead ------------------------------------------------------------- +p.append(f'Indigo Syntax') +p.append(f'TWO FORMATTING LANGUAGES · ONE FAMILY') +p.append(f'linuxpop-format-* · linuxpop-md-*') +p.append(f'48 × 48 · symbolic + colour') +p.append(f'') + +# --- the core distinction, stated once, large ---------------------------- +hy = 210 +p.append(f'THE DIFFERENCE') +# rich-text B +p.append(place_color(M.g_bold(M.GLYPH_COL, True), "", 150, hy + 42, 96, "hb")) +p.append(f'Rich-text') +p.append(f'presses Ctrl+B · styles in place') +p.append(f'linuxpop-format-bold') +# markdown B +p.append(place_color(M.g_bold(M.GLYPH_COL, True), M.badge(None, True), 800, hy + 42, 96, "mb")) +p.append(f'Markdown') +p.append(f'wraps in **bold** · the red .md tag') +p.append(f'linuxpop-md-bold') +p.append(f'') + +# --- two columns: families ------------------------------------------------ +colA, colB = 80, 800 +ty = hy + 210 +p.append(f'RICH-TEXT · linuxpop-format-*') +p.append(f'MARKDOWN · linuxpop-md-*') + +row_y = ty + 34 +rh = 74 + +# rich-text rows +for i, (key, label, fn) in enumerate(M.FORMAT_DEF): + y = row_y + i * rh + p.append(sym_swatch(fn(SYM, False), "", colA + 30, y)) + p.append(place_color(fn(M.GLYPH_COL, True), "", colA + 74, y, 52, f"f{i}")) + p.append(f'{label}') + p.append(f'Ctrl+{label[0]}') + +# markdown rows +for i, (key, label, token, fn) in enumerate(M.MD_DEF): + y = row_y + i * rh + amber = key == "highlight" + p.append(sym_swatch(fn(SYM, amber), M.badge(None, False), colB + 30, y)) + p.append(place_color(fn(M.GLYPH_COL, amber), M.badge(None, True), colB + 74, y, 52, f"m{i}")) + p.append(f'{label}') + tok = token.replace("&", "&").replace("<", "<").replace(">", ">") + sample = f'{tok}text{tok[::-1]}' if key not in ("quote", "link") else ( + '> text' if key == "quote" else '[text](url)') + p.append(f'{sample}') + +# --- footer: real-size popup row ----------------------------------------- +fy = H - 96 +p.append(f'') +p.append(f'IN THE POPUP, AT SIZE — the red tag picks out the markdown actions') +bar_x, bar_y, g = 80, fy + 22, 34 +mix = [(M.g_bold, False, ""), (M.g_italic, False, ""), (M.g_underline, False, ""), + (M.g_bold, False, M.badge(None, False)), (M.g_highlight, True, M.badge(None, False)), + (M.g_quote, False, M.badge(None, False)), (M.g_code, False, M.badge(None, False)), + (M.g_link, False, M.badge(None, False))] +bw = g * len(mix) + 30 +p.append(f'') +gx = bar_x + 15 +for fn, amber, em in mix: + p.append(place_sym(fn(SYM, amber), em, gx, bar_y + 9, g)) + gx += g +p.append(f'' + f'3 rich-text · 5 markdown') + +svg = (f'' + "".join(p) + "") +out = os.path.join(HERE, "markdown-icons-specimen.png") +cairosvg.svg2png(bytestring=svg.encode(), write_to=out, output_width=W * 2, output_height=H * 2) +print("wrote", out) diff --git a/docs/design/category-grouping-mock.png b/docs/design/category-grouping-mock.png new file mode 100644 index 0000000..ea17b50 Binary files /dev/null and b/docs/design/category-grouping-mock.png differ diff --git a/docs/design/markdown-icons-philosophy.md b/docs/design/markdown-icons-philosophy.md new file mode 100644 index 0000000..72d151b --- /dev/null +++ b/docs/design/markdown-icons-philosophy.md @@ -0,0 +1,46 @@ +# Indigo Syntax + +*A glyph system for the formatting popup — two languages, one family.* + +## Philosophy + +Formatting in a popup speaks two different languages that look deceptively +alike. One *styles text in place* — the editor's own bold, the live italic of a +word processor. The other *inserts visible marks* — the two asterisks, the +backtick, the angle bracket of markup. They share a vocabulary of words like +"bold" and "code," yet they do opposite things, and a serious icon system must +make that difference legible at a glance. **Indigo Syntax** is the discipline of +rendering both with the same painstaking hand while never letting the eye +confuse them. + +One field, one ink. Every tile is the same rounded indigo plate, lit by a single +top-to-bottom gradient and ringed by a hairline bezel a fraction of a pixel +inside the edge. The restraint is the point: when the container never varies, +the glyph is free to carry the meaning, and the glyphs become a family rather +than a crowd. Recovering that cohesion — after a popup row of borrowed, +mismatched theme icons — is the quiet, master-level work that looks effortless +only because it was laboured over. + +The base glyph names the action. Rich-text styling is an act of typesetting, so +it wears serif letterforms — B, I, U — cut with the deliberate stroke contrast +of real type. The structural marks of markup — the highlighter band, the +blockquote rule, the chevrons of code, the interlocked link — are drawn instead +in even, round-capped strokes, because they describe structure, not letters. A +reader never has to be told which is which; the silhouette already said it. + +The earmark names the language. Where an action belongs to markup, a single red +`.md` tag sits in the lower-right corner — the unmistakable colour and shape of a +file extension, the one warm note in a cool indigo field. Its job is narrow and +absolute: to say *markdown*, instantly, even at sixteen pixels where the glyph +beneath it is only a suggestion. Rich-text actions wear no tag at all. The +presence or absence of that red mark is the whole distinction, and tuning it to +read at popup scale without shouting on the catalogue plate took countless +passes. + +Two bodies for every mark. A monochrome glyph for the dark popup row, where it +must hold its own beside its siblings, and a full-colour plate for the catalogue +and the manager, where it can be admired. Same geometry, same spacing, same +earmark, same soul — only the skin changes. Drawing a thing twice and making +both feel inevitable is the standard here: not "good enough for a toolbar," but +pristine enough to mount on a wall. + diff --git a/docs/design/markdown-icons-specimen.png b/docs/design/markdown-icons-specimen.png new file mode 100644 index 0000000..bf63c7f Binary files /dev/null and b/docs/design/markdown-icons-specimen.png differ diff --git a/docs/develop.html b/docs/develop.html index 3e88c5b..bbadf61 100644 --- a/docs/develop.html +++ b/docs/develop.html @@ -3,10 +3,26 @@ - Build your own plugin - LinuxPop - + Build your own LinuxPop plugin: no-code wizard & Python API + + + + + + + + + + + + + + + + + diff --git a/docs/index.html b/docs/index.html index 51816f6..13ace4a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -3,10 +3,91 @@ - LinuxPop - PopClip-inspired text actions for Linux - + LinuxPop: PopClip for Linux (X11 & KDE Plasma Wayland) + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -28,9 +109,9 @@

Text actions for Linux,
without leaving where you are.

- Highlight anything. A small popup appears with context-aware actions - - open URLs, run commands, ask an AI, transform text. Inspired by PopClip - on macOS, built native for X11. + Highlight anything. A small popup appears with actions that fit what you + grabbed: open URLs, run commands, ask an AI, transform text. It's the + PopClip idea from macOS, done for Linux. Works on X11 and KDE Plasma 6 Wayland.

Install LinuxPop @@ -44,34 +125,28 @@

What's in the box

Twelve plugins ship out of the box. Add your own with a no-code wizard or a 20-line Python file.

-

Selection-aware popup

Different buttons for URLs, shell commands, emails, paths and plain text - the classifier figures it out.

-

Global hotkeys

-

Summon the popup on the current selection, or open the clipboard + snippets picker, from anywhere on your desktop.

+

Summon the popup on the current selection, or open the clipboard and snippets picker, from anywhere on your desktop.

-
📋

Clipboard & snippets

Built-in history (text and images) with pin/name to turn anything into a permanent named snippet.

-

AI without lock-in

-

Send to Claude, ChatGPT, Gemini, Perplexity or Google AI - or run local models via Ollama. No keys, no accounts.

+

Send to Claude, ChatGPT, Gemini, Perplexity or Google AI, or run local models via Ollama. No keys, no accounts.

-
🧩

No-code wizard

Build a custom action in 30 seconds with a GUI form. Pick an icon from 3 800+, write a URL template, done.

-
🪶

Lightweight

-

Event-driven via XFixes - 55 MB resident, 0 % CPU at idle. Doesn't fight your laptop.

+

Event-driven via XFixes: 55 MB resident, 0 % CPU at idle. Doesn't fight your laptop.

@@ -80,21 +155,28 @@

Lightweight

Install

-

X11 desktop required. Tested on Linux Mint Cinnamon; works on Ubuntu, Fedora, Arch.

+

Runs on X11 (Cinnamon, GNOME, XFCE, MATE) and natively on KDE Plasma 6 Wayland.

+ +

Flatpak (recommended)

+

Auto-updating, GPG-signed, works on every distro:

+
flatpak install --from https://gaimsdevsoftware.github.io/linuxpop-flatpak/linuxpop.flatpakref
+flatpak run io.github.GaimsDevSoftware.LinuxPop
+ +

Native packages

+
# Debian / Ubuntu / Linux Mint - grab the .deb from the latest release
+sudo apt install ./linuxpop_0.9.2_all.deb
 
-    

1. System dependencies

-
# Ubuntu / Linux Mint / Debian
-sudo apt-get install -y python3 python3-gi python3-gi-cairo gir1.2-gtk-3.0 \
-    gir1.2-handy-1 gir1.2-ayatanaappindicator3-0.1 \
-    xclip xdotool xdg-utils python3-xlib
+# Fedora (COPR) +sudo dnf copr enable gaimsdevsoftware/linuxpop +sudo dnf install linuxpop
-

2. Clone & install

+

From source

git clone https://github.com/GaimsDevSoftware/linuxpop.git ~/linuxpop
 cd ~/linuxpop
 bash install.sh
 python3 ~/linuxpop/main.py    # start now without logging out
-

3. Use it

+

Use it

  • Highlight any text on screen → popup appears with actions
  • Press Super+Shift+Y → popup on current selection at cursor
  • diff --git a/docs/plugins.html b/docs/plugins.html index 669ff16..1920aac 100644 --- a/docs/plugins.html +++ b/docs/plugins.html @@ -3,10 +3,26 @@ - Plugin Store - LinuxPop - + Plugin Store: LinuxPop plugins for Linux text actions + + + + + + + + + + + + + + + + + diff --git a/docs/robots.txt b/docs/robots.txt new file mode 100644 index 0000000..e2aa3ed --- /dev/null +++ b/docs/robots.txt @@ -0,0 +1,28 @@ +# LinuxPop - https://gaimsdevsoftware.github.io/linuxpop/ +# All crawlers welcome, including AI answer engines. +User-agent: * +Allow: / + +# Explicitly welcome AI / answer-engine crawlers (GEO/AEO) +User-agent: GPTBot +Allow: / +User-agent: OAI-SearchBot +Allow: / +User-agent: ChatGPT-User +Allow: / +User-agent: ClaudeBot +Allow: / +User-agent: Claude-Web +Allow: / +User-agent: anthropic-ai +Allow: / +User-agent: PerplexityBot +Allow: / +User-agent: Google-Extended +Allow: / +User-agent: Applebot-Extended +Allow: / +User-agent: CCBot +Allow: / + +Sitemap: https://gaimsdevsoftware.github.io/linuxpop/sitemap.xml diff --git a/docs/sitemap.xml b/docs/sitemap.xml new file mode 100644 index 0000000..9fd5fcf --- /dev/null +++ b/docs/sitemap.xml @@ -0,0 +1,18 @@ + + + + https://gaimsdevsoftware.github.io/linuxpop/ + weekly + 1.0 + + + https://gaimsdevsoftware.github.io/linuxpop/plugins.html + weekly + 0.8 + + + https://gaimsdevsoftware.github.io/linuxpop/develop.html + monthly + 0.6 + + diff --git a/icons/linuxpop-ai-translate-en-symbolic.svg b/icons/linuxpop-ai-translate-en-symbolic.svg new file mode 100644 index 0000000..148b5f3 --- /dev/null +++ b/icons/linuxpop-ai-translate-en-symbolic.svg @@ -0,0 +1 @@ +EN \ No newline at end of file diff --git a/icons/linuxpop-ai-translate-no-symbolic.svg b/icons/linuxpop-ai-translate-no-symbolic.svg new file mode 100644 index 0000000..774f79a --- /dev/null +++ b/icons/linuxpop-ai-translate-no-symbolic.svg @@ -0,0 +1 @@ +NO \ No newline at end of file diff --git a/icons/linuxpop-format-bold-symbolic.svg b/icons/linuxpop-format-bold-symbolic.svg new file mode 100644 index 0000000..c71c4d1 --- /dev/null +++ b/icons/linuxpop-format-bold-symbolic.svg @@ -0,0 +1 @@ +B \ No newline at end of file diff --git a/icons/linuxpop-format-bold.svg b/icons/linuxpop-format-bold.svg new file mode 100644 index 0000000..6d3bdd0 --- /dev/null +++ b/icons/linuxpop-format-bold.svg @@ -0,0 +1 @@ +B \ No newline at end of file diff --git a/icons/linuxpop-format-italic-symbolic.svg b/icons/linuxpop-format-italic-symbolic.svg new file mode 100644 index 0000000..6534619 --- /dev/null +++ b/icons/linuxpop-format-italic-symbolic.svg @@ -0,0 +1 @@ +I \ No newline at end of file diff --git a/icons/linuxpop-format-italic.svg b/icons/linuxpop-format-italic.svg new file mode 100644 index 0000000..df588a2 --- /dev/null +++ b/icons/linuxpop-format-italic.svg @@ -0,0 +1 @@ +I \ No newline at end of file diff --git a/icons/linuxpop-format-symbolic.svg b/icons/linuxpop-format-symbolic.svg new file mode 100644 index 0000000..c328d48 --- /dev/null +++ b/icons/linuxpop-format-symbolic.svg @@ -0,0 +1 @@ +A \ No newline at end of file diff --git a/icons/linuxpop-format-underline-symbolic.svg b/icons/linuxpop-format-underline-symbolic.svg new file mode 100644 index 0000000..d7b8744 --- /dev/null +++ b/icons/linuxpop-format-underline-symbolic.svg @@ -0,0 +1 @@ +U \ No newline at end of file diff --git a/icons/linuxpop-format-underline.svg b/icons/linuxpop-format-underline.svg new file mode 100644 index 0000000..40b911e --- /dev/null +++ b/icons/linuxpop-format-underline.svg @@ -0,0 +1 @@ +U \ No newline at end of file diff --git a/icons/linuxpop-format.svg b/icons/linuxpop-format.svg new file mode 100644 index 0000000..a6b70a3 --- /dev/null +++ b/icons/linuxpop-format.svg @@ -0,0 +1 @@ +A \ No newline at end of file diff --git a/icons/linuxpop-google-ai-symbolic.svg b/icons/linuxpop-google-ai-symbolic.svg index ea2c2a7..1f35019 100644 --- a/icons/linuxpop-google-ai-symbolic.svg +++ b/icons/linuxpop-google-ai-symbolic.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/icons/linuxpop-google-ai.svg b/icons/linuxpop-google-ai.svg index 7ffb571..7efdfc7 100644 --- a/icons/linuxpop-google-ai.svg +++ b/icons/linuxpop-google-ai.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/icons/linuxpop-html-decode-symbolic.svg b/icons/linuxpop-html-decode-symbolic.svg new file mode 100644 index 0000000..acf7437 --- /dev/null +++ b/icons/linuxpop-html-decode-symbolic.svg @@ -0,0 +1 @@ +&A \ No newline at end of file diff --git a/icons/linuxpop-html-encode-symbolic.svg b/icons/linuxpop-html-encode-symbolic.svg new file mode 100644 index 0000000..abc5f69 --- /dev/null +++ b/icons/linuxpop-html-encode-symbolic.svg @@ -0,0 +1 @@ +A& \ No newline at end of file diff --git a/icons/linuxpop-md-bold-symbolic.svg b/icons/linuxpop-md-bold-symbolic.svg new file mode 100644 index 0000000..dd09e75 --- /dev/null +++ b/icons/linuxpop-md-bold-symbolic.svg @@ -0,0 +1 @@ +B.md \ No newline at end of file diff --git a/icons/linuxpop-md-bold.svg b/icons/linuxpop-md-bold.svg new file mode 100644 index 0000000..2c1ff93 --- /dev/null +++ b/icons/linuxpop-md-bold.svg @@ -0,0 +1 @@ +B.md \ No newline at end of file diff --git a/icons/linuxpop-md-code-symbolic.svg b/icons/linuxpop-md-code-symbolic.svg new file mode 100644 index 0000000..44170c8 --- /dev/null +++ b/icons/linuxpop-md-code-symbolic.svg @@ -0,0 +1 @@ +.md \ No newline at end of file diff --git a/icons/linuxpop-md-code.svg b/icons/linuxpop-md-code.svg new file mode 100644 index 0000000..5009865 --- /dev/null +++ b/icons/linuxpop-md-code.svg @@ -0,0 +1 @@ +.md \ No newline at end of file diff --git a/icons/linuxpop-md-highlight-symbolic.svg b/icons/linuxpop-md-highlight-symbolic.svg new file mode 100644 index 0000000..47325ee --- /dev/null +++ b/icons/linuxpop-md-highlight-symbolic.svg @@ -0,0 +1 @@ +.md \ No newline at end of file diff --git a/icons/linuxpop-md-highlight.svg b/icons/linuxpop-md-highlight.svg new file mode 100644 index 0000000..db304db --- /dev/null +++ b/icons/linuxpop-md-highlight.svg @@ -0,0 +1 @@ +.md \ No newline at end of file diff --git a/icons/linuxpop-md-italic-symbolic.svg b/icons/linuxpop-md-italic-symbolic.svg new file mode 100644 index 0000000..aa105fc --- /dev/null +++ b/icons/linuxpop-md-italic-symbolic.svg @@ -0,0 +1 @@ +I.md \ No newline at end of file diff --git a/icons/linuxpop-md-italic.svg b/icons/linuxpop-md-italic.svg new file mode 100644 index 0000000..4f0b56e --- /dev/null +++ b/icons/linuxpop-md-italic.svg @@ -0,0 +1 @@ +I.md \ No newline at end of file diff --git a/icons/linuxpop-md-link-symbolic.svg b/icons/linuxpop-md-link-symbolic.svg new file mode 100644 index 0000000..738c378 --- /dev/null +++ b/icons/linuxpop-md-link-symbolic.svg @@ -0,0 +1 @@ +.md \ No newline at end of file diff --git a/icons/linuxpop-md-link.svg b/icons/linuxpop-md-link.svg new file mode 100644 index 0000000..dcff0bc --- /dev/null +++ b/icons/linuxpop-md-link.svg @@ -0,0 +1 @@ +.md \ No newline at end of file diff --git a/icons/linuxpop-md-quote-symbolic.svg b/icons/linuxpop-md-quote-symbolic.svg new file mode 100644 index 0000000..84a5303 --- /dev/null +++ b/icons/linuxpop-md-quote-symbolic.svg @@ -0,0 +1 @@ +.md \ No newline at end of file diff --git a/icons/linuxpop-md-quote.svg b/icons/linuxpop-md-quote.svg new file mode 100644 index 0000000..d06eb0b --- /dev/null +++ b/icons/linuxpop-md-quote.svg @@ -0,0 +1 @@ +.md \ No newline at end of file diff --git a/icons/linuxpop-md-strikethrough-symbolic.svg b/icons/linuxpop-md-strikethrough-symbolic.svg new file mode 100644 index 0000000..efc55a7 --- /dev/null +++ b/icons/linuxpop-md-strikethrough-symbolic.svg @@ -0,0 +1 @@ +S.md \ No newline at end of file diff --git a/icons/linuxpop-md-strikethrough.svg b/icons/linuxpop-md-strikethrough.svg new file mode 100644 index 0000000..031c578 --- /dev/null +++ b/icons/linuxpop-md-strikethrough.svg @@ -0,0 +1 @@ +S.md \ No newline at end of file diff --git a/icons/linuxpop-md-symbolic.svg b/icons/linuxpop-md-symbolic.svg new file mode 100644 index 0000000..30f0b9e --- /dev/null +++ b/icons/linuxpop-md-symbolic.svg @@ -0,0 +1 @@ +.md \ No newline at end of file diff --git a/icons/linuxpop-md.svg b/icons/linuxpop-md.svg new file mode 100644 index 0000000..20ffeae --- /dev/null +++ b/icons/linuxpop-md.svg @@ -0,0 +1 @@ +.md \ No newline at end of file diff --git a/plugin_base.py b/plugin_base.py index 8c42144..70c6d8d 100644 --- a/plugin_base.py +++ b/plugin_base.py @@ -32,6 +32,11 @@ class Plugin: # Cut / Paste / Backspace / Bold / Italic / Underline - actions that # have no effect on read-only text and would just confuse the user. requires_editable: bool = False + # Optional grouping key. When popup_group_categories is on, plugins that + # share a category collapse behind a single category chip in the popup + # (click to expand its members). Known keys live in plugin_loader.CATEGORIES. + # None means the plugin always shows inline. + category: Optional[str] = None def handles(self, content_type: ContentType) -> bool: if not self.content_types: diff --git a/plugin_loader.py b/plugin_loader.py index 683fd92..c8f7bd8 100644 --- a/plugin_loader.py +++ b/plugin_loader.py @@ -241,6 +241,60 @@ def sort_key(p: Plugin): return matched +# Known plugin categories. A plugin sets Plugin.category to one of these keys; +# the popup collapses each group behind a chip (icon + label) that expands to +# its members. Order here is irrelevant - chips appear at the position of the +# group's first member, so plugin_order still controls placement. +CATEGORIES: dict[str, dict[str, str]] = { + "format": {"label": "Formatting", "icon": "linuxpop-format-symbolic"}, + "markdown": {"label": "Markdown", "icon": "linuxpop-md-symbolic"}, +} + + +def plan_grouped(plugins, *, group: bool, min_size: int, + categories: dict | None = None) -> list[tuple]: + """Turn an ordered plugin list into a popup display plan. + + Returns a list of entries, preserving the incoming order: + ("action", plugin) + ("category", key, label, icon, [member plugins]) + + A category collapses into one chip only when it has at least `min_size` + members present; smaller groups stay inline as plain actions (no point + hiding one button behind a chip). With group=False every plugin is an + inline action, i.e. today's behaviour. Pure/GTK-free so it can be tested. + """ + cats = CATEGORIES if categories is None else categories + if not group: + return [("action", p) for p in plugins] + + members: dict[str, list] = {} + skeleton: list[tuple] = [] + for p in plugins: + key = getattr(p, "category", None) + if key and key in cats: + if key not in members: + members[key] = [] + skeleton.append(("catref", key)) + members[key].append(p) + else: + skeleton.append(("action", p)) + + out: list[tuple] = [] + for entry in skeleton: + if entry[0] != "catref": + out.append(entry) + continue + key = entry[1] + group_members = members[key] + if len(group_members) >= max(2, min_size): + meta = cats[key] + out.append(("category", key, meta["label"], meta["icon"], group_members)) + else: + out.extend(("action", m) for m in group_members) + return out + + def _register_builtins() -> None: # Universal: copy works on anything register(Plugin( @@ -313,7 +367,7 @@ def _can_run(text: str) -> bool: # EMAIL register(Plugin( name="compose-email", - icon="mail-send-symbolic", + icon="mail-message-new-symbolic", tooltip="Compose email", handler=actions.compose_email, content_types=(ContentType.EMAIL,), diff --git a/plugin_manager.py b/plugin_manager.py index 894b5d8..8c90989 100644 --- a/plugin_manager.py +++ b/plugin_manager.py @@ -744,9 +744,10 @@ def _build_order_group(self) -> Handy.PreferencesGroup: group = Handy.PreferencesGroup() group.set_title("Popup button order") group.set_description( - "Drag-free reordering: ↑ and ↓ buttons move a plugin up or down. " - "The popup shows plugins in this order (those not listed fall back " - "to their built-in priority)." + "Drag a plugin by its handle (≡) to drop it anywhere in the list, " + "or use the ↑ / ↓ buttons for one step at a time. The popup shows " + "plugins in this order (those not listed fall back to their " + "built-in priority)." ) self._fill_order_group(group) return group @@ -777,10 +778,26 @@ def _fill_order_group(self, group: Handy.PreferencesGroup) -> None: ), ) + # Full ordered name list, kept in sync for drag-and-drop + arrow moves. + self._order_names = [p.name for p in sorted_plugins] + dnd_targets = [Gtk.TargetEntry.new( + "LINUXPOP_ORDER_ROW", Gtk.TargetFlags.SAME_APP, 0)] + for index, plugin in enumerate(sorted_plugins): row = Handy.ActionRow() row.set_title(plugin.tooltip or plugin.name) row.set_subtitle(plugin.name) + row._order_index = index + row._order_icon = plugin.icon + row._plugin_name = plugin.name + # Right-click context menu: jump to top/bottom + hide the plugin. + try: + gesture = Gtk.GestureMultiPress.new(row) + gesture.set_button(3) # secondary / right click + gesture.connect("pressed", self._on_order_row_rightclick, row) + row._ctx_gesture = gesture # keep a ref so it isn't GC'd + except Exception as exc: # noqa: BLE001 + print(f"[plugin_manager] context-menu setup failed: {exc}") try: img = Gtk.Image.new_from_icon_name(plugin.icon, Gtk.IconSize.LARGE_TOOLBAR) img.set_pixel_size(20) @@ -788,6 +805,30 @@ def _fill_order_group(self, group: Handy.PreferencesGroup) -> None: except Exception: pass + # Drag-and-drop: the whole row is a drag source, and every row is a + # drop target. Dropping onto another row moves this plugin to that + # slot. The ↑/↓ buttons stay for precise / keyboard-driven moves. + try: + row.drag_source_set(Gdk.ModifierType.BUTTON1_MASK, dnd_targets, + Gdk.DragAction.MOVE) + row.drag_dest_set(Gtk.DestDefaults.ALL, dnd_targets, + Gdk.DragAction.MOVE) + row.connect("drag-begin", self._on_order_drag_begin) + row.connect("drag-data-get", self._on_order_drag_data_get) + row.connect("drag-data-received", self._on_order_drag_data_received) + theme = Gtk.IconTheme.get_default() + if theme.has_icon("list-drag-handle-symbolic"): + grip = Gtk.Image.new_from_icon_name( + "list-drag-handle-symbolic", Gtk.IconSize.BUTTON) + else: + grip = Gtk.Label(label="⣿") # braille block, grip-like + grip.set_valign(Gtk.Align.CENTER) + grip.set_tooltip_text("Drag to reorder") + grip.get_style_context().add_class("dim-label") + row.add(grip) + except Exception as exc: # noqa: BLE001 + print(f"[plugin_manager] drag-reorder setup failed: {exc}") + up_btn = Gtk.Button.new_from_icon_name("go-up-symbolic", Gtk.IconSize.BUTTON) up_btn.set_valign(Gtk.Align.CENTER) up_btn.set_sensitive(index > 0) @@ -819,14 +860,16 @@ def _on_move(self, _btn, index: int, delta: int, sorted_plugins) -> None: new_idx = index + delta if new_idx < 0 or new_idx >= len(sorted_plugins): return - ordered_names = [p.name for p in sorted_plugins] - ordered_names[index], ordered_names[new_idx] = ( - ordered_names[new_idx], ordered_names[index], - ) + names = [p.name for p in sorted_plugins] + names[index], names[new_idx] = names[new_idx], names[index] + self._persist_order(names) + + def _persist_order(self, ordered_names) -> None: + """Save the full plugin order, then refresh the list and the popup.""" try: from settings import get_settings s = get_settings() - s.set("plugin_order", ordered_names) + s.set("plugin_order", list(ordered_names)) s.save() except Exception as exc: # noqa: BLE001 print(f"[plugin_manager] could not save order: {exc}") @@ -835,6 +878,90 @@ def _on_move(self, _btn, index: int, delta: int, sorted_plugins) -> None: if self._on_changed: self._on_changed() + # ---- drag-and-drop reordering ------------------------------------------- + def _on_order_drag_begin(self, row, context) -> None: + # Show the plugin's own icon under the cursor while dragging. + try: + Gtk.drag_set_icon_name( + context, getattr(row, "_order_icon", "view-list-symbolic"), 8, 8) + except Exception: + pass + + def _on_order_drag_data_get(self, row, context, data, info, time) -> None: + data.set_text(str(getattr(row, "_order_index", -1)), -1) + + def _on_order_drag_data_received(self, row, context, x, y, data, info, time) -> None: + try: + src = int(data.get_text()) + except (TypeError, ValueError): + return + dst = getattr(row, "_order_index", -1) + names = list(getattr(self, "_order_names", []) or []) + if not (0 <= src < len(names)) or not (0 <= dst < len(names)) or src == dst: + return + # Move the dragged plugin to the dropped-on row's position. + names.insert(dst, names.pop(src)) + self._persist_order(names) + + # ---- right-click context menu ------------------------------------------- + def _on_order_row_rightclick(self, gesture, n_press, x, y, row) -> None: + name = getattr(row, "_plugin_name", None) + if not name: + return + menu = Gtk.Menu() + + def add(label, callback, sensitive=True): + item = Gtk.MenuItem(label=label) + item.set_sensitive(sensitive) + item.connect("activate", lambda _i: callback()) + menu.append(item) + + names = list(getattr(self, "_order_names", []) or []) + at_top = names and names[0] == name + at_bottom = names and names[-1] == name + add("Move to top", lambda: self._order_move_to(name, "top"), not at_top) + add("Move to bottom", lambda: self._order_move_to(name, "bottom"), not at_bottom) + menu.append(Gtk.SeparatorMenuItem()) + add("Hide from popup", lambda: self._hide_plugin(name)) + menu.show_all() + try: + menu.popup_at_pointer(gesture.get_last_event(None)) + except Exception: + menu.popup_at_pointer(None) + + def _order_move_to(self, name: str, where: str) -> None: + names = list(getattr(self, "_order_names", []) or []) + if name not in names: + return + names.remove(name) + names.insert(0, name) if where == "top" else names.append(name) + self._persist_order(names) + + def _hide_plugin(self, name: str) -> None: + """Hide a plugin from the popup by adding it to disabled_plugins. + Reversible: re-enable it from the Installed tab.""" + try: + from settings import get_settings + s = get_settings() + disabled = list(s.get("disabled_plugins") or []) + if name not in disabled: + disabled.append(name) + s.set("disabled_plugins", disabled) + s.save() + except Exception as exc: # noqa: BLE001 + print(f"[plugin_manager] hide failed: {exc}") + return + # Reload so the daemon drops it from the popup, then refresh the list + # (the hidden plugin leaves the Order tab; unhide it under Installed). + if self._on_changed: + try: + self._on_changed() + except Exception: + pass + self._refresh_order_group() + if getattr(self, "_installed_group", None) is not None: + self._refresh_installed_group() + def _rebuild_catalog_buttons(self) -> None: page = getattr(self, "_catalog_page", None) if page is None: diff --git a/plugins_repo/clipboard_history.py b/plugins_repo/clipboard_history.py index cb60b13..f466c3c 100644 --- a/plugins_repo/clipboard_history.py +++ b/plugins_repo/clipboard_history.py @@ -2814,7 +2814,7 @@ def _ask_edit_snippet_meta( orientation=Gtk.Orientation.HORIZONTAL, margin_top=10) spacer = Gtk.Label() guide_row.pack_start(spacer, True, True, 0) - guide_btn = Gtk.Button(label="📖 Snippet guide") + guide_btn = Gtk.Button(label="Snippet guide") guide_btn.connect( "clicked", lambda _b: self._show_snippet_help_dialog(parent=dlg)) guide_row.pack_start(guide_btn, False, False, 0) @@ -2995,7 +2995,7 @@ def _ask_new_snippet(self) -> Optional[Tuple[str, str, str, str]]: "Click to insert a placeholder (filled in at paste time):" ) help_row.pack_start(help_intro, True, True, 0) - help_btn = Gtk.Button(label="📖 Snippet guide") + help_btn = Gtk.Button(label="Snippet guide") help_btn.set_tooltip_text( "Open a friendly walkthrough of snippets, triggers, and placeholders." ) diff --git a/plugins_repo/formatting.py b/plugins_repo/formatting.py index ce29bd8..953b1a1 100644 --- a/plugins_repo/formatting.py +++ b/plugins_repo/formatting.py @@ -25,30 +25,40 @@ def _send(combo: str) -> None: def register(register_plugin) -> None: + # Use the bundled house-style glyphs (icons/linuxpop-format-*) rather than + # freedesktop format-text-* theme names: the theme icons render in + # whatever style the active desktop ships (and can be missing entirely + # under some KDE themes or in the Flatpak sandbox), which left these + # sitting visually foreign next to the rest of the popup row. These are + # the plain letterforms; the markdown variants (linuxpop-md-*) carry a + # red ".md" earmark so the two mechanisms never look the same. register_plugin(Plugin( name="format-bold", - icon="format-text-bold-symbolic", + icon="linuxpop-format-bold-symbolic", tooltip="Bold", handler=lambda _t: _send("ctrl+b"), content_types=(), priority=40, requires_editable=True, + category="format", )) register_plugin(Plugin( name="format-italic", - icon="format-text-italic-symbolic", + icon="linuxpop-format-italic-symbolic", tooltip="Italic", handler=lambda _t: _send("ctrl+i"), content_types=(), priority=41, requires_editable=True, + category="format", )) register_plugin(Plugin( name="format-underline", - icon="format-text-underline-symbolic", + icon="linuxpop-format-underline-symbolic", tooltip="Underline", handler=lambda _t: _send("ctrl+u"), content_types=(), priority=42, requires_editable=True, + category="format", )) diff --git a/plugins_repo/html_entities.py b/plugins_repo/html_entities.py index 1921498..657948a 100644 --- a/plugins_repo/html_entities.py +++ b/plugins_repo/html_entities.py @@ -48,9 +48,9 @@ def _decode(text: str) -> None: def register(register_plugin) -> None: types = (ContentType.PLAIN_TEXT,) - register_plugin(Plugin(name="html-entity-encode", icon="text-html-symbolic", + register_plugin(Plugin(name="html-entity-encode", icon="linuxpop-html-encode-symbolic", tooltip="HTML entity encode", handler=_encode, content_types=types, priority=75, predicate=_has_escapable_chars)) - register_plugin(Plugin(name="html-entity-decode", icon="text-html-symbolic", + register_plugin(Plugin(name="html-entity-decode", icon="linuxpop-html-decode-symbolic", tooltip="HTML entity decode", handler=_decode, content_types=types, priority=76, predicate=_has_html_entities)) diff --git a/plugins_repo/markdown.py b/plugins_repo/markdown.py new file mode 100644 index 0000000..3301306 --- /dev/null +++ b/plugins_repo/markdown.py @@ -0,0 +1,57 @@ +"""Markdown wrapping - bold, italic, strikethrough, highlight, quote, code, link. + +Unlike the rich-text Formatting plugin (which presses Ctrl+B and lets the +editor style the text), these wrap the selection in literal markdown syntax +and paste it back over the selection. Handy in any plain-text markdown editor, +note app, chat box, commit message or issue field. + +They share the "markdown" category, so with popup_group_categories on they +collapse behind one "Markdown" chip. Each carries the red ".md" earmark icon +so it never gets confused with the rich-text formatting buttons. +""" +from __future__ import annotations + +import actions +from plugin_base import Plugin + + +def _wrap(prefix: str, suffix: str): + def handler(text: str) -> None: + actions.replace_selection(f"{prefix}{text}{suffix}") + return handler + + +def _quote(text: str) -> None: + # Blockquote every line of the selection. + lines = text.split("\n") + actions.replace_selection("\n".join(f"> {ln}" for ln in lines)) + + +def _link(text: str) -> None: + # Inline link with an empty target for the user to fill in. + actions.replace_selection(f"[{text}]()") + + +_ACTIONS = [ + ("md-bold", "linuxpop-md-bold-symbolic", "Bold (**)", _wrap("**", "**"), 50), + ("md-italic", "linuxpop-md-italic-symbolic", "Italic (*)", _wrap("*", "*"), 51), + ("md-strikethrough", "linuxpop-md-strikethrough-symbolic", "Strikethrough (~~)", _wrap("~~", "~~"), 52), + ("md-highlight", "linuxpop-md-highlight-symbolic", "Highlight (==)", _wrap("==", "=="), 53), + ("md-quote", "linuxpop-md-quote-symbolic", "Quote (>)", _quote, 54), + ("md-code", "linuxpop-md-code-symbolic", "Inline code (`)", _wrap("`", "`"), 55), + ("md-link", "linuxpop-md-link-symbolic", "Link ([]())", _link, 56), +] + + +def register(register_plugin) -> None: + for name, icon, tooltip, handler, priority in _ACTIONS: + register_plugin(Plugin( + name=name, + icon=icon, + tooltip=tooltip, + handler=handler, + content_types=(), + priority=priority, + requires_editable=True, + category="markdown", + )) diff --git a/plugins_repo/ollama_ai.py b/plugins_repo/ollama_ai.py index 049707f..56432f6 100644 --- a/plugins_repo/ollama_ai.py +++ b/plugins_repo/ollama_ai.py @@ -180,7 +180,7 @@ def register(register_plugin) -> None: )) register_plugin(Plugin( name="ai-translate-en", - icon="preferences-desktop-locale-symbolic", + icon="linuxpop-ai-translate-en-symbolic", tooltip="AI: translate to English", handler=_translate_to_en, content_types=types, @@ -188,7 +188,7 @@ def register(register_plugin) -> None: )) register_plugin(Plugin( name="ai-translate-no", - icon="preferences-desktop-locale-symbolic", + icon="linuxpop-ai-translate-no-symbolic", tooltip="AI: translate to Norwegian", handler=_translate_to_no, content_types=types, diff --git a/popup.py b/popup.py index 4d51492..c7d1b5e 100644 --- a/popup.py +++ b/popup.py @@ -355,6 +355,7 @@ def __init__( self.win.connect("leave-notify-event", self._on_leave) self._current_text: str = "" + self._open_category: str | None = None self._hide_timeout_id: int | None = None self._tracker_id: int | None = None # Absolute ceiling on time the popup will linger if the user @@ -405,6 +406,8 @@ def _clear_buttons(self) -> None: # Collapse row 2 until something gets added to it. self._row2.hide() self._row2.set_no_show_all(True) + # No category chip is expanded on a fresh popup. + self._open_category = None def _add_button( self, icon_name: str, tooltip: str, on_click, @@ -675,6 +678,10 @@ def show_for( # the focused widget - see main.py / editable_detect.py. if not editable: plugins = [p for p in plugins if not p.requires_editable] + # Keep the full, uncapped list for the grouped view: category chips + # collapse their members, so the flat cap below (which is about how + # many *icons* fit) shouldn't pre-trim members that live behind a chip. + plugins_all = list(plugins) # Hard cap so the popup doesn't grow to 25 icons across when # many plugins are installed. for_content_type already returns # in priority/custom-order, so the first N are the highest- @@ -726,8 +733,14 @@ def _worker(): self.hide() return _on_click - specs = [(p.icon, p.tooltip, make_handler(p)) for p in plugins] - self._present_buttons(specs, hidden_count, max_btns) + if self._group_categories_enabled(): + plan = plugin_loader.plan_grouped( + plugins_all, group=True, min_size=self._category_min(), + categories=plugin_loader.CATEGORIES) + self._present_plan(plan, make_handler, max_btns) + else: + specs = [(p.icon, p.tooltip, make_handler(p)) for p in plugins] + self._present_buttons(specs, hidden_count, max_btns) self._present_near(x, y, rect=rect) def _add_overflow_chip( @@ -819,6 +832,82 @@ def _present_buttons(self, specs, hidden_count: int, max_btns: int) -> None: extra, max_btns, row=self._row2 if shown > per_row else self._row1) + # ---- category grouping ------------------------------------------------ + def _group_categories_enabled(self) -> bool: + try: + from settings import get_settings as _gs + return bool(_gs().get("popup_group_categories")) + except Exception: + return False + + def _category_min(self) -> int: + try: + from settings import get_settings as _gs + return max(2, int(_gs().get("popup_category_min") or 2)) + except Exception: + return 2 + + def _present_plan(self, plan, make_handler, max_btns: int) -> None: + """Lay out a grouped plan (action + category entries) on row 1. + Category chips expand their members onto row 2 on click.""" + per_row = self._max_per_row() + shown = plan[:per_row] + for entry in shown: + if entry[0] == "category": + _, _key, label, icon, members = entry + specs = [(p.icon, p.tooltip, make_handler(p)) for p in members] + self._add_category_chip(icon, label, specs) + else: + p = entry[1] + self._add_button(p.icon, p.tooltip, make_handler(p), row=self._row1) + extra = len(plan) - len(shown) + if extra > 0: + self._add_overflow_chip(extra, max_btns, row=self._row1) + + def _add_category_chip(self, icon: str, label: str, member_specs) -> None: + """A chip that, on click, reveals its category's members on row 2 + (and collapses them again on a second click).""" + n = len(member_specs) + btn = Gtk.Button() + btn.set_relief(Gtk.ReliefStyle.NONE) + btn.set_tooltip_text( + f"{label} — {n} action{'s' if n != 1 else ''} (click to expand)") + btn.get_style_context().add_class("linuxpop-action") + btn.get_style_context().add_class("linuxpop-category") + btn.set_image(self._make_icon_image(icon)) + btn.set_always_show_image(True) + + def _toggle(_b): + already_open = self._open_category == label + for child in list(self._row2.get_children()): + self._row2.remove(child) + child.destroy() + if already_open: + self._open_category = None + self._row2.hide() + self._row2.set_no_show_all(True) + self._try_resize() + return + self._open_category = label + per_row = self._max_per_row() + for ic, tip, h in member_specs[:per_row]: + self._add_button(ic, tip, h, row=self._row2) + hidden = n - min(n, per_row) + if hidden > 0: + self._add_overflow_chip(hidden, 0, row=self._row2) + self._row2.set_no_show_all(False) + self._row2.show_all() + self._try_resize() + + btn.connect("clicked", _toggle) + self._row1.pack_start(btn, False, False, 0) + + def _try_resize(self) -> None: + try: + self.win.queue_resize() + except Exception: + pass + def _add_expand_chip(self, rest, hidden_count: int, max_btns: int) -> None: """A chevron at the end of row 1; clicking it reveals row 2 with the actions that didn't fit on the first line ('expand' overflow mode).""" diff --git a/settings.py b/settings.py index 7bd171e..bcbb948 100644 --- a/settings.py +++ b/settings.py @@ -229,6 +229,15 @@ # "cap" -> one row only; the remainder sit behind a "+N" chip. # All modes are still bounded by max_popup_buttons above. "popup_overflow_mode": "expand", + # Collapse plugins that share a category (Formatting, Markdown, ...) behind + # a single chip in the popup; clicking the chip expands its members onto the + # second row. Declutters the bar when families like the markdown actions are + # installed. Turn off to show every action inline. + "popup_group_categories": True, + # A category only collapses into a chip once it has at least this many + # members in the popup; smaller groups stay inline (no point hiding one + # button behind a chip). + "popup_category_min": 2, # Icon style for branded/utility plugins: # "color" -> vibrant gradient tiles (default) # "glyph" -> uniform mono glyphs that match the plain text-edit icons diff --git a/settings_gui.py b/settings_gui.py index 5eca7e9..3bab848 100644 --- a/settings_gui.py +++ b/settings_gui.py @@ -788,6 +788,23 @@ def _on_tray_icon_changed(combo: Gtk.ComboBoxText) -> None: ov_row.set_activatable_widget(ov_combo) group.add(ov_row) + # Category grouping: collapse plugins that share a category (Formatting, + # Markdown) behind one chip that expands on click. + grp_row = Handy.ActionRow() + grp_row.set_title("Group actions by category") + grp_row.set_subtitle( + "Collapse families like Formatting and Markdown behind a single " + "chip in the popup; click the chip to reveal its actions on a " + "second row. Turn off to show every action inline.") + grp_switch = Gtk.Switch() + grp_switch.set_valign(Gtk.Align.CENTER) + grp_switch.set_active(bool(self._settings.get("popup_group_categories"))) + grp_switch.connect( + "notify::active", self._on_switch, "popup_group_categories") + grp_row.add(grp_switch) + grp_row.set_activatable_widget(grp_switch) + group.add(grp_row) + return group def _build_activation_group(self) -> Handy.PreferencesGroup: diff --git a/test_min_selection_filter.py b/test_min_selection_filter.py new file mode 100644 index 0000000..a93ae79 --- /dev/null +++ b/test_min_selection_filter.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Regression tests for the minimum-selection-length filter boundary. + +The "skip short selections" knob (min_selection_length_enabled + +min_selection_length) must only ever gate the *auto-popup on selection* +path. The two double-click-in-empty-field and hotkey paths show the +edit menu directly and must NOT be affected by it. + +These tests pin that boundary so a future refactor can't quietly leak +the filter into the no-selection popup path. + +They exercise the App methods in isolation against a stub `self`, so no +X11/GTK main loop or real backend is needed. +""" +import sys +import types + +import pytest + +# The app imports GTK via gobject-introspection; skip cleanly where that +# (or a display) isn't available, same as the other GUI-touching test. +pytest.importorskip("gi") +sys.argv = ["test"] +try: + import main +except Exception as exc: # pragma: no cover - environment without GTK/display + pytest.skip(f"cannot import main ({exc})", allow_module_level=True) + + +class FakeSettings: + """Minimal stand-in for the settings singleton.""" + + def __init__(self, **values): + self._values = values + + def get(self, key, default=None): + return self._values.get(key, default) + + +# Settings that would suppress every short selection on the watcher path. +AGGRESSIVE_MIN = dict( + min_selection_length_enabled=True, + min_selection_length=100, + blocklist_patterns=[], + clipboard_history_enabled=True, + selection_debounce_ms=0, +) + + +def _double_click_stub(): + return types.SimpleNamespace( + _show_no_selection_popup=_Recorder(), + ) + + +class _Recorder: + """Callable that records the calls made to it.""" + + def __init__(self): + self.calls = [] + + def __call__(self, *args, **kwargs): + self.calls.append((args, kwargs)) + + +# --- the actual regression: empty-field double-click ignores the filter ---- + +def test_double_click_empty_field_fires_despite_min_length(monkeypatch): + """ctrl+double-click in an editable empty field must still pop the edit + menu even with an aggressive minimum-character setup.""" + monkeypatch.setattr(main, "is_focus_editable", lambda *a, **k: True) + stub = _double_click_stub() + + main.App._on_global_double_click(stub, 11, 22) + + assert stub._show_no_selection_popup.calls == [((11, 22), {})], \ + "empty-field double-click must open the no-selection popup" + + +def test_double_click_non_editable_does_nothing(monkeypatch): + """The only gate on the double-click path is 'is the focus editable', + never the minimum-length setting.""" + monkeypatch.setattr(main, "is_focus_editable", lambda *a, **k: False) + stub = _double_click_stub() + + main.App._on_global_double_click(stub, 11, 22) + + assert stub._show_no_selection_popup.calls == [] + + +def test_no_selection_popup_builds_menu_with_min_length_on(monkeypatch): + """_show_no_selection_popup never consults the min-length filter: it + shows the edit menu whenever the active window isn't blocklisted.""" + monkeypatch.setattr(main, "_active_window_blocked", lambda patterns: False) + popup = types.SimpleNamespace(show_actions=_Recorder()) + stub = types.SimpleNamespace( + settings=FakeSettings(**AGGRESSIVE_MIN), + popup=popup, + _on_clipboard_hotkey=lambda *a, **k: None, + ) + + main.App._show_no_selection_popup(stub, 5, 6) + + assert len(popup.show_actions.calls) == 1, "the edit menu must be shown" + (items, x, y), _ = popup.show_actions.calls[0] + assert (x, y) == (5, 6) + assert items, "the edit menu must contain at least one action" + + +def test_no_selection_popup_still_respects_blocklist(monkeypatch): + """Contrast: the blocklist IS allowed to suppress the popup. This keeps + the test honest about what does and doesn't gate this path.""" + monkeypatch.setattr(main, "_active_window_blocked", lambda patterns: True) + popup = types.SimpleNamespace(show_actions=_Recorder()) + stub = types.SimpleNamespace( + settings=FakeSettings(**AGGRESSIVE_MIN), + popup=popup, + ) + + main.App._show_no_selection_popup(stub, 5, 6) + + assert popup.show_actions.calls == [] + + +# --- the other side of the boundary: the watcher path DOES filter ---------- + +def _capture_watcher_callback(monkeypatch, stub): + """Run _start_watcher against a fake backend and return the on_selection + callback it registered.""" + captured = {} + + class FakeWatcher: + def __init__(self, cb): + captured["cb"] = cb + + def start(self): + pass + + def stop(self): + pass + + fake_backend = types.SimpleNamespace( + make_selection_watcher=lambda cb, debounce: FakeWatcher(cb), + ) + monkeypatch.setattr(main, "get_backend", lambda: fake_backend) + main.App._start_watcher(stub) + return captured["cb"] + + +def test_watcher_path_filters_short_selection(monkeypatch): + scheduled = [] + monkeypatch.setattr(main.GLib, "idle_add", lambda *a, **k: scheduled.append(a)) + stub = types.SimpleNamespace( + watcher=None, + _watcher_active=False, + min_len=100, + settings=FakeSettings(**AGGRESSIVE_MIN), + _show_for_text=lambda *a, **k: None, + ) + + on_selection = _capture_watcher_callback(monkeypatch, stub) + on_selection("ab", 1, 2) # 2 chars < 100, filter enabled + + assert scheduled == [], "short selection must be filtered on the watcher path" + + +def test_watcher_path_shows_when_filter_disabled(monkeypatch): + scheduled = [] + monkeypatch.setattr(main.GLib, "idle_add", lambda *a, **k: scheduled.append(a)) + settings = dict(AGGRESSIVE_MIN) + settings["min_selection_length_enabled"] = False + stub = types.SimpleNamespace( + watcher=None, + _watcher_active=False, + min_len=100, + settings=FakeSettings(**settings), + _show_for_text=lambda *a, **k: None, + ) + + on_selection = _capture_watcher_callback(monkeypatch, stub) + on_selection("ab", 1, 2) + + assert len(scheduled) == 1, "with the filter off, the popup must be scheduled" + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"]))