commit 6e050e98d3e0b50211d488dc2e1386df1b7cecfc Author: Lucas Rayan Date: Sat Oct 3 09:37:27 2026 -0300 Copiado: histórico da área de transferência no Win+V Co-Authored-By: Claude Opus 5.5 diff --git a/.github/workflows/pacote.yml b/.github/workflows/pacote.yml new file mode 100644 index 0000000..1f58d2a --- /dev/null +++ b/.github/workflows/pacote.yml @@ -0,0 +1,52 @@ +name: Pacote + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + +permissions: + contents: write + +jobs: + pacote: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Verificar código + run: | + python3 -m py_compile copiado.py + shellcheck instalar.sh desinstalar.sh empacotamento/gerar-deb.sh + + - name: Definir versão + run: | + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + echo "VERSAO=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + else + echo "VERSAO=0.0.0~dev${GITHUB_RUN_NUMBER}" >> "$GITHUB_ENV" + fi + + - name: Gerar .deb + run: empacotamento/gerar-deb.sh "$VERSAO" + + - name: Testar instalação do pacote + run: | + sudo apt-get update -qq + sudo apt-get install -y ./dist/copiado_all.deb + test -x /usr/bin/copiado + test -f /etc/xdg/autostart/copiado.desktop + + - uses: actions/upload-artifact@v4 + with: + name: copiado-${{ env.VERSAO }} + path: dist/copiado_all.deb + + - name: Publicar release + if: startsWith(github.ref, 'refs/tags/v') + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" dist/copiado_all.deb \ + --title "Copiado $VERSAO" --generate-notes diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ead01d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +dist/ +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..17e397f --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# Copiado + +O histórico da área de transferência do Windows, no Linux Mint. +Aperte **Win+V** e ele aparece, no visual do Windows 11. +Clique em um item e ele é colado na hora. + +## Instalar + +```bash +curl -fsSL https://raw.githubusercontent.com/lucasrguerra/copiado/main/instalar.sh | bash +``` + +Baixa o pacote `.deb` mais recente das [Releases](https://github.com/lucasrguerra/copiado/releases), +instala, cria o atalho Win+V e já deixa rodando. Pede sua senha (sudo). +Para **atualizar**, rode o mesmo comando de novo. + +## Usar + +| Ação | Como | +|---|---| +| Abrir / fechar | **Win+V** | +| Colar um item | clique, ou setas + **Enter** | +| Fixar (não some ao limpar) | ícone de alfinete | +| Apagar um item | ícone da lixeira ou tecla **Delete** | +| Apagar tudo (menos os fixados) | **Limpar tudo** | +| Fechar sem colar | **Esc** ou clicar fora | + +Guarda os 25 últimos itens (texto e imagens) e continua lá depois de reiniciar o PC. + +## Desinstalar + +```bash +curl -fsSL https://raw.githubusercontent.com/lucasrguerra/copiado/main/desinstalar.sh | bash +``` + +## Se algo der errado + +- **Win+V não abre nada:** faça o Cinnamon recarregar os atalhos: + ```bash + gsettings set org.cinnamon.desktop.keybindings custom-list "[]"; gsettings set org.cinnamon.desktop.keybindings custom-list "['custom0']" + ``` + Se ainda assim não abrir, veja se outro atalho usa Win+V em + *Configurações do Sistema → Teclado → Atalhos*. +- **Abre, mas o histórico está vazio:** o app pode não estar rodando. Rode + `copiado &` + +## Publicar uma versão nova + +```bash +git tag v1.0.1 && git push origin v1.0.1 +``` + +O GitHub Actions gera o `copiado_all.deb`, testa a instalação e publica a release. +Para gerar o pacote localmente: `empacotamento/gerar-deb.sh 1.0.1` (sai em `dist/`). diff --git a/copiado.py b/copiado.py new file mode 100755 index 0000000..82a9587 --- /dev/null +++ b/copiado.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Copiado: histórico da área de transferência no estilo Windows 11 (Win+V). + +Uso: + copiado.py inicia em segundo plano (vigia o Ctrl+C) + copiado.py show abre a janelinha do histórico +""" +import hashlib +import json +import os +import subprocess +import sys +import time + +import gi +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +gi.require_version("GdkX11", "3.0") +from gi.repository import Gdk, GdkPixbuf, GdkX11, Gio, GLib, Gtk, Pango # noqa: E402 + +MAX_ITENS = 25 +LARGURA, ALTURA = 380, 480 +PASTA = os.path.join(GLib.get_user_data_dir(), "copiado") +ARQ_HIST = os.path.join(PASTA, "historico.json") + +CSS = b""" +#fundo { + background-color: rgba(32, 32, 32, 0.97); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; +} +#titulo { color: #ffffff; font-size: 15px; font-weight: 600; } +#limpar { + color: #9fc9ff; background: transparent; border: none; box-shadow: none; + font-size: 12px; padding: 4px 8px; border-radius: 6px; +} +#limpar:hover { background-color: rgba(255, 255, 255, 0.06); } +list { background: transparent; } +row { background: transparent; padding: 0; outline: none; } +row:focus, row:hover, row:selected { background: transparent; } +.cartao { + background-color: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + padding: 10px 12px; + margin: 3px 0; +} +row:hover .cartao { background-color: rgba(255, 255, 255, 0.09); } +row:focus .cartao, row:selected .cartao { + background-color: rgba(255, 255, 255, 0.09); + border-color: #4cc2ff; +} +.texto { color: #f3f3f3; font-size: 13px; } +.icone { + background: transparent; border: none; box-shadow: none; + color: #a0a0a0; padding: 2px 4px; min-width: 0; min-height: 0; border-radius: 4px; +} +.icone:hover { background-color: rgba(255, 255, 255, 0.10); color: #ffffff; } +.fixado, .fixado:hover { color: #4cc2ff; } +#vazio { color: #a0a0a0; font-size: 13px; } +scrollbar { background: transparent; border: none; } +scrollbar slider { background-color: rgba(255,255,255,0.25); border-radius: 4px; min-width: 4px; } +""" + + +class Historico: + def __init__(self): + os.makedirs(PASTA, exist_ok=True) + self.itens = [] # {"tipo": "texto"|"imagem", "dado": str, "fixo": bool} + try: + with open(ARQ_HIST) as f: + self.itens = json.load(f) + except (OSError, ValueError): + pass + + def salvar(self): + with open(ARQ_HIST + ".tmp", "w") as f: + json.dump(self.itens, f, ensure_ascii=False) + os.replace(ARQ_HIST + ".tmp", ARQ_HIST) + usadas = {i["dado"] for i in self.itens if i["tipo"] == "imagem"} + for nome in os.listdir(PASTA): + caminho = os.path.join(PASTA, nome) + if nome.endswith(".png") and caminho not in usadas: + os.remove(caminho) + + def adicionar(self, tipo, dado): + antigo = next((i for i in self.itens if i["tipo"] == tipo and i["dado"] == dado), None) + fixo = antigo["fixo"] if antigo else False + if antigo: + self.itens.remove(antigo) + self.itens.insert(0, {"tipo": tipo, "dado": dado, "fixo": fixo}) + soltos = [i for i in self.itens if not i["fixo"]] + for extra in soltos[MAX_ITENS:]: + self.itens.remove(extra) + self.salvar() + + def remover(self, item): + self.itens.remove(item) + self.salvar() + + def limpar(self): + self.itens = [i for i in self.itens if i["fixo"]] + self.salvar() + + +class App(Gtk.Application): + def __init__(self): + super().__init__(application_id="io.github.copiado", + flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE) + self.hist = None + self.janela = None + self.ignorar = None # conteúdo que nós mesmos colocamos + + # ---------- ciclo de vida ---------- + def do_startup(self): + Gtk.Application.do_startup(self) + self.hold() + self.hist = Historico() + prov = Gtk.CssProvider() + prov.load_from_data(CSS) + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), prov, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + self.clip = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + self.clip.connect("owner-change", self.ao_copiar) + self.criar_janela() + + def do_command_line(self, linha): + if "show" in linha.get_arguments()[1:]: + self.mostrar() + return 0 + + # ---------- captura ---------- + def ao_copiar(self, clip, _evento): + if clip.wait_is_image_available(): + img = clip.wait_for_image() + if img is None: + return + dados = img.save_to_bufferv("png", [], [])[1] + nome = hashlib.sha1(dados).hexdigest()[:16] + ".png" + caminho = os.path.join(PASTA, nome) + if caminho == self.ignorar: + return + if not os.path.exists(caminho): + with open(caminho, "wb") as f: + f.write(dados) + self.hist.adicionar("imagem", caminho) + else: + texto = clip.wait_for_text() + if not texto or not texto.strip() or texto == self.ignorar: + return + self.hist.adicionar("texto", texto) + if self.janela.get_visible(): + self.preencher() + + # ---------- janela ---------- + def criar_janela(self): + j = Gtk.Window(type=Gtk.WindowType.TOPLEVEL) + self.add_window(j) + j.set_decorated(False) + j.set_resizable(False) + j.set_skip_taskbar_hint(True) + j.set_skip_pager_hint(True) + j.set_keep_above(True) + j.set_type_hint(Gdk.WindowTypeHint.DIALOG) + j.set_default_size(LARGURA, ALTURA) + visual = j.get_screen().get_rgba_visual() + if visual: + j.set_visual(visual) + j.set_app_paintable(True) + j.connect("draw", self.limpar_fundo) + j.connect("key-press-event", self.ao_tecla) + j.connect("focus-out-event", lambda *_: self.esconder()) + j.connect("delete-event", lambda *_: self.esconder() or True) + + caixa = Gtk.Box() + caixa.set_name("fundo") + fundo = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + for m in ("top", "bottom", "start", "end"): + getattr(fundo, f"set_margin_{m}")(12) + caixa.pack_start(fundo, True, True, 0) + + topo = Gtk.Box(spacing=8) + titulo = Gtk.Label(label="Área de transferência", xalign=0) + titulo.set_name("titulo") + limpar = Gtk.Button(label="Limpar tudo") + limpar.set_name("limpar") + limpar.connect("clicked", lambda *_: (self.hist.limpar(), self.preencher())) + topo.pack_start(titulo, True, True, 0) + topo.pack_end(limpar, False, False, 0) + + self.lista = Gtk.ListBox() + self.lista.set_selection_mode(Gtk.SelectionMode.NONE) + self.lista.connect("row-activated", lambda _l, row: self.colar(row.item)) + rolagem = Gtk.ScrolledWindow() + rolagem.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + rolagem.add(self.lista) + + self.vazio = Gtk.Label( + label="Nada aqui ainda\n\nCopie algo com Ctrl+C e ele\naparecerá neste histórico.", + justify=Gtk.Justification.CENTER) + self.vazio.set_name("vazio") + + self.pilha = Gtk.Stack() + self.pilha.add_named(rolagem, "lista") + self.pilha.add_named(self.vazio, "vazio") + + fundo.pack_start(topo, False, False, 0) + fundo.pack_start(self.pilha, True, True, 0) + j.add(caixa) + self.janela = j + + @staticmethod + def limpar_fundo(_w, cr): + cr.set_source_rgba(0, 0, 0, 0) + cr.set_operator(0) # CLEAR + cr.paint() + cr.set_operator(2) # OVER + return False + + def cartao(self, item): + row = Gtk.ListBoxRow() + row.item = item + caixa = Gtk.Box(spacing=8) + caixa.get_style_context().add_class("cartao") + + if item["tipo"] == "imagem": + try: + pb = GdkPixbuf.Pixbuf.new_from_file_at_scale(item["dado"], 300, 110, True) + conteudo = Gtk.Image.new_from_pixbuf(pb) + conteudo.set_halign(Gtk.Align.START) + except GLib.Error: + conteudo = Gtk.Label(label="[imagem]", xalign=0) + else: + texto = item["dado"].strip() + if len(texto) > 400: + texto = texto[:400] + "…" + conteudo = Gtk.Label(label=texto, xalign=0, yalign=0) + conteudo.set_line_wrap(True) + conteudo.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR) + conteudo.set_lines(3) + conteudo.set_ellipsize(Pango.EllipsizeMode.END) + conteudo.set_max_width_chars(1) + conteudo.get_style_context().add_class("texto") + + botoes = Gtk.Box(spacing=2) + botoes.set_valign(Gtk.Align.START) + fixar = Gtk.Button.new_from_icon_name("view-pin-symbolic", Gtk.IconSize.MENU) + fixar.set_tooltip_text("Desafixar" if item["fixo"] else "Fixar") + fixar.get_style_context().add_class("icone") + if item["fixo"]: + fixar.get_style_context().add_class("fixado") + fixar.connect("clicked", self.alternar_fixo, item) + apagar = Gtk.Button.new_from_icon_name("user-trash-symbolic", Gtk.IconSize.MENU) + apagar.set_tooltip_text("Excluir") + apagar.get_style_context().add_class("icone") + apagar.connect("clicked", lambda *_: (self.hist.remover(item), self.preencher())) + botoes.pack_start(fixar, False, False, 0) + botoes.pack_start(apagar, False, False, 0) + + caixa.pack_start(conteudo, True, True, 0) + caixa.pack_end(botoes, False, False, 0) + row.add(caixa) + return row + + def alternar_fixo(self, _btn, item): + item["fixo"] = not item["fixo"] + self.hist.salvar() + self.preencher() + + def preencher(self): + for filho in self.lista.get_children(): + self.lista.remove(filho) + for item in self.hist.itens: + self.lista.add(self.cartao(item)) + self.lista.show_all() + self.pilha.set_visible_child_name("lista" if self.hist.itens else "vazio") + primeira = self.lista.get_row_at_index(0) + if primeira: + primeira.grab_focus() + + def mostrar(self): + if self.janela.get_visible(): + self.esconder() + return + self.preencher() + # posiciona perto do mouse, sem sair da tela + tela = Gdk.Display.get_default() + _s, x, y = tela.get_default_seat().get_pointer().get_position()[0:3] + geo = tela.get_monitor_at_point(x, y).get_workarea() + x = max(geo.x + 8, min(x - LARGURA // 2, geo.x + geo.width - LARGURA - 8)) + y = max(geo.y + 8, min(y - 40, geo.y + geo.height - ALTURA - 8)) + self.janela.move(x, y) + self.janela.show_all() + self.pilha.set_visible_child_name("lista" if self.hist.itens else "vazio") + gw = self.janela.get_window() + gw.focus(GdkX11.x11_get_server_time(gw)) + self.janela.present_with_time(GdkX11.x11_get_server_time(gw)) + + def esconder(self): + self.janela.hide() + return False + + def ao_tecla(self, _w, ev): + if ev.keyval == Gdk.KEY_Escape: + self.esconder() + return True + if ev.keyval == Gdk.KEY_Delete: + row = self.janela.get_focus() + if isinstance(row, Gtk.ListBoxRow): + self.hist.remover(row.item) + self.preencher() + return True + return False + + # ---------- colar ---------- + def colar(self, item): + if item["tipo"] == "imagem": + try: + self.clip.set_image(GdkPixbuf.Pixbuf.new_from_file(item["dado"])) + except GLib.Error: + return + else: + self.clip.set_text(item["dado"], -1) + self.clip.store() + self.ignorar = item["dado"] + # sobe o item para o topo, como no Windows + self.hist.itens.remove(item) + self.hist.itens.insert(0, item) + self.hist.salvar() + self.esconder() + GLib.timeout_add(150, self.enviar_ctrl_v) + + def enviar_ctrl_v(self): + tecla = "ctrl+v" + try: + classe = subprocess.run( + ["xdotool", "getactivewindow", "getwindowclassname"], + capture_output=True, text=True, timeout=1).stdout.lower() + if any(t in classe for t in ("term", "tilix", "kitty", "alacritty", "konsole")): + tecla = "ctrl+shift+v" + subprocess.run(["xdotool", "key", "--clearmodifiers", tecla], timeout=2) + except (OSError, subprocess.SubprocessError): + pass + GLib.timeout_add(500, self.liberar_ignorar) + return False + + def liberar_ignorar(self): + self.ignorar = None + return False + + +if __name__ == "__main__": + sys.exit(App().run(sys.argv)) diff --git a/desinstalar.sh b/desinstalar.sh new file mode 100755 index 0000000..687528a --- /dev/null +++ b/desinstalar.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Remove o Copiado e o atalho Win+V +BASE=org.cinnamon.desktop.keybindings +LISTA=$(gsettings get $BASE custom-list) +for ID in $(python3 -c "import ast,sys; print(' '.join(ast.literal_eval(sys.argv[1].replace('@as ',''))))" "$LISTA"); do + SCHEMA="$BASE.custom-keybinding:/org/cinnamon/desktop/keybindings/custom-keybindings/$ID/" + if gsettings get "$SCHEMA" command | grep -q copiado; then + LISTA=$(python3 -c "import ast,sys; l=ast.literal_eval(sys.argv[1].replace('@as ','')); print([x for x in l if x!='$ID'])" "$LISTA") + gsettings set $BASE custom-list "$LISTA" + gsettings reset-recursively "$SCHEMA" + fi +done +pkill -f "/usr/bin/copiado" 2>/dev/null +sudo apt-get remove -y copiado +rm -rf "$HOME/.local/share/copiado" +echo "Copiado removido. (O histórico salvo também foi apagado.)" diff --git a/empacotamento/gerar-deb.sh b/empacotamento/gerar-deb.sh new file mode 100755 index 0000000..21e373c --- /dev/null +++ b/empacotamento/gerar-deb.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Gera o pacote copiado_all.deb. Uso: empacotamento/gerar-deb.sh 1.2.3 +set -e +VERSAO="${1:?informe a versão, ex.: 1.0.0}" +cd "$(dirname "$0")/.." +RAIZ=$(mktemp -d) +chmod 755 "$RAIZ" +trap 'rm -rf "$RAIZ"' EXIT + +install -Dm755 copiado.py "$RAIZ/usr/bin/copiado" +install -Dm644 README.md "$RAIZ/usr/share/doc/copiado/README.md" +install -Dm644 /dev/stdin "$RAIZ/etc/xdg/autostart/copiado.desktop" < "$RAIZ/DEBIAN/control" < +Homepage: https://github.com/lucasrguerra/copiado +Description: Histórico da área de transferência no estilo Windows 11 + Aperte Win+V para ver os últimos itens copiados e colar com um clique. +CTRL + +mkdir -p dist +dpkg-deb --root-owner-group --build "$RAIZ" dist/copiado_all.deb +echo "Gerado: dist/copiado_all.deb ($VERSAO)" diff --git a/instalar.sh b/instalar.sh new file mode 100755 index 0000000..0fa329b --- /dev/null +++ b/instalar.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Instala (ou atualiza) o Copiado a partir do pacote publicado no GitHub +set -e +URL="https://github.com/lucasrguerra/copiado/releases/latest/download/copiado_all.deb" +BASE=org.cinnamon.desktop.keybindings +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +echo "==> Baixando a versão mais recente..." +curl -fsSL -o "$TMP/copiado_all.deb" "$URL" +chmod 644 "$TMP/copiado_all.deb" + +echo "==> Instalando o pacote..." +sudo apt-get update -qq || echo " (aviso: algum repositório falhou no update, seguindo mesmo assim)" +sudo apt-get install -y "$TMP/copiado_all.deb" + +# remove instalações antigas feitas sem pacote (o histórico é mantido) +pkill -f "share/copiado/copiado.py" 2>/dev/null || true +pkill -f "share/clipboard-winv/clipboard-winv.py" 2>/dev/null || true +rm -f "$HOME/.local/share/copiado/copiado.py" \ + "$HOME/.config/autostart/copiado.desktop" \ + "$HOME/.config/autostart/clipboard-winv.desktop" +if [ -f "$HOME/.local/share/clipboard-winv/historico.json" ]; then + mkdir -p "$HOME/.local/share/copiado" + cp -a "$HOME/.local/share/clipboard-winv"/. "$HOME/.local/share/copiado"/ + sed -i "s#/clipboard-winv/#/copiado/#g" "$HOME/.local/share/copiado/historico.json" + rm -rf "$HOME/.local/share/clipboard-winv" +fi + +echo "==> Criando atalho Win+V..." +LISTA=$(gsettings get $BASE custom-list) +ID="" +for i in $(python3 -c "import ast,sys; print(' '.join(ast.literal_eval(sys.argv[1].replace('@as ',''))))" "$LISTA"); do + B=$(gsettings get "$BASE.custom-keybinding:/org/cinnamon/desktop/keybindings/custom-keybindings/$i/" binding) + [ "$B" = "['v']" ] && ID=$i && break +done +if [ -z "$ID" ]; then + for i in $(seq 0 99); do + echo "$LISTA" | grep -q "'custom$i'" || { ID="custom$i"; break; } + done + LISTA=$(python3 -c "import ast,sys; l=ast.literal_eval(sys.argv[1].replace('@as ','')); l.append('$ID'); print(l)" "$LISTA") +fi +SCHEMA="$BASE.custom-keybinding:/org/cinnamon/desktop/keybindings/custom-keybindings/$ID/" +gsettings set "$SCHEMA" name "Copiado" +gsettings set "$SCHEMA" command "copiado show" +gsettings set "$SCHEMA" binding "['v']" +# só agora registra a lista (e força o Cinnamon a recarregar os atalhos) +gsettings set $BASE custom-list "[]" +sleep 0.5 +gsettings set $BASE custom-list "$LISTA" + +echo "==> Iniciando..." +pkill -x copiado 2>/dev/null || pkill -f "/usr/bin/copiado" 2>/dev/null || true +setsid copiado >/dev/null 2>&1 & + +echo +echo "Pronto! Versão instalada: $(dpkg-query -W -f='${Version}' copiado). Aperte Win+V."