Copiado: histórico da área de transferência no Win+V
Pacote / pacote (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5.5 committed 2026-10-03 09:37:27 -03:00
commit 6e050e98d3
7 files changed
+572

No files matched your search

Executable
+353
View File
@@ -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))