""" Atalho global do openTARS: uma combinação de teclas (padrão Ctrl+Alt+Espaço) que abre a barra rápida de qualquer lugar. Quem "escuta" a tecla é o próprio ambiente de trabalho: o openTARS só cadastra um atalho personalizado nas configurações dele, igual a quem vai em Configurações > Teclado > Atalhos personalizados. Aparece lá, dá pra mudar ou apagar por lá também. Suportados: GNOME (Ubuntu, Zorin, Pop!_OS, Budgie), Cinnamon (Mint), MATE e XFCE. No KDE e nos demais, o comando a cadastrar à mão é "opentars-gui --rapido". Por que não Super+Espaço: no GNOME/Zorin ele já troca o layout do teclado, e dois atalhos iguais brigam. Uso: opentars --atalho mostra a situação opentars --atalho ctrl+alt+space cadastra/troca opentars --atalho off remove """ import os import re import shlex import shutil import subprocess import sys from pathlib import Path import tars_i18n as i18n import tars_plataforma as plataforma from tars_i18n import t PADRAO = "space" NOME = "openTARS" MARCA_COMANDO = "--rapido" # 5.2: o atalho das ações de texto (painel com o texto selecionado). Por enquanto só no GNOME; nos outros, à mão. ACOES_NOME = "openTARS: ações de texto" ACOES_MARCA = "--acoes" ACOES_PADRAO = "r" # decisão do Enzo (23/10): Ctrl+Shift+Espaço brigava com o VS Code TIMEOUT_SEG = 5 GNOME_SCHEMA = "org.gnome.settings-daemon.plugins.media-keys" GNOME_CAMINHO = "/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/" CINNAMON_SCHEMA = "org.cinnamon.desktop.keybindings" CINNAMON_CAMINHO = "/org/cinnamon/desktop/keybindings/custom-keybindings/" MATE_ATALHOS = "org.mate.Marco.global-keybindings" MATE_COMANDOS = "org.mate.Marco.keybinding-commands" XFCE_CANAL = "xfce4-keyboard-shortcuts" # Esquemas onde um atalho do próprio sistema pode já usar a combinação. ESQUEMAS_DO_SISTEMA = { "gnome": ("org.gnome.desktop.wm.keybindings", "org.gnome.shell.keybindings", "org.gnome.mutter.keybindings", GNOME_SCHEMA), "cinnamon": ("org.cinnamon.desktop.keybindings.wm", "org.cinnamon.desktop.keybindings.media-keys"), "mate": ("org.mate.Marco.global-keybindings", "org.mate.Marco.window-keybindings"), } # Nomes de tecla como o GTK espera (o resto: letra minúscula, F1..F12). NOMES_TECLAS = { "space": "space", "espaco": "space", "espaço": "space", "espacio": "space", "espace": "space", "leertaste": "space", "enter": "Return", "return": "Return", "tab": "Tab", "esc": "Escape", "escape": "Escape", "home": "Home", "end": "End", "pageup": "Page_Up", "pagedown": "Page_Down", "insert": "Insert", "delete": "Delete", "backspace": "BackSpace", } MODIFICADORES = { "ctrl": "", "control": "", "primary": "", "strg": "", "alt": "", "shift": "", "super": "", "win": "", "meta": "", "mod4": "", } # ------------------------------------------------------------ # Combinação de teclas # ------------------------------------------------------------ def normalizar_combinacao(texto): """"Ctrl+Alt+Space", "space", "super+t" -> formato GTK ("space"). None se não der pra entender.""" texto = (texto or "").strip() if not texto: return None if "<" in texto: mods = re.findall(r"<([^>]+)>", texto) tecla = re.sub(r"<[^>]+>", "", texto).strip() else: partes = [p.strip() for p in texto.replace("-", "+").split("+") if p.strip()] if not partes: return None mods, tecla = partes[:-1], partes[-1] saida = [] for mod in mods: convertido = MODIFICADORES.get(mod.lower()) if not convertido: return None if convertido not in saida: saida.append(convertido) if not tecla or not saida: return None nome = tecla.lower() if nome in NOMES_TECLAS: tecla = NOMES_TECLAS[nome] elif re.fullmatch(r"f\d{1,2}", nome): tecla = nome.upper() elif len(tecla) == 1: tecla = nome ordem = ["", "", "", ""] return "".join(sorted(saida, key=ordem.index)) + tecla def legivel(combinacao): """"space" -> "Ctrl+Alt+Espaço" (no idioma escolhido).""" nomes = {"": "Ctrl", "": "Ctrl", "": "Alt", "": "Shift", "": "Super"} mods = re.findall(r"<[^>]+>", combinacao or "") tecla = re.sub(r"<[^>]+>", "", combinacao or "") tecla = t("atalho.tecla_espaco") if tecla.lower() == "space" else tecla.upper() if len(tecla) == 1 else tecla.capitalize() return "+".join([nomes.get(m, m.strip("<>")) for m in mods] + [tecla]) def _mesma(a, b): return normalizar_combinacao(a) == normalizar_combinacao(b) def _eh_nosso(comando, marca=MARCA_COMANDO): comando = comando or "" return marca in comando and ("opentars" in comando.lower() or "tars_gui" in comando) def comando_barra_rapida(marca=MARCA_COMANDO): lancador = plataforma.lancador("opentars-gui") if lancador: return f"{lancador} {marca}" gui = Path(__file__).resolve().with_name("tars_gui.py") return f"{shlex.quote(sys.executable)} {shlex.quote(str(gui))} {marca}" # ------------------------------------------------------------ # Execução de comandos do sistema # ------------------------------------------------------------ def _rodar(*cmd): try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=TIMEOUT_SEG) return r.returncode, r.stdout.strip() except Exception as e: return -1, str(e) def _gsettings_get(esquema, chave): codigo, saida = _rodar("gsettings", "get", esquema, chave) return saida if codigo == 0 else None def _gsettings_set(esquema, chave, valor): return _rodar("gsettings", "set", esquema, chave, valor)[0] == 0 def _lista_gvariant(texto): """"['a', 'b']", "@as []" ou uma string solta -> ['a', 'b']. O gsettings escreve entre aspas DUPLAS quando o texto tem aspa simples (ex.: o comando com caminho entre aspas), por isso lê com o literal do Python.""" import ast t = re.sub(r"^\s*@\w+\s+", "", texto or "").strip() try: v = ast.literal_eval(t) if isinstance(v, str): return [v] if isinstance(v, (list, tuple)): return [str(x) for x in v] return [] except (ValueError, SyntaxError): return re.findall(r"'((?:[^'\\]|\\.)*)'", texto or "") def _gvariant_texto(valor): return "'" + valor.replace("\\", "\\\\").replace("'", "\\'") + "'" def _gvariant_lista(valores): return "[" + ", ".join(_gvariant_texto(v) for v in valores) + "]" if valores else "@as []" def _esquema_existe(esquema): codigo, saida = _rodar("gsettings", "list-schemas") return codigo == 0 and esquema in saida.split() def _conflito_no_sistema(ambiente, combinacao): """Nome da ação do sistema que já usa a combinação, se houver.""" for esquema in ESQUEMAS_DO_SISTEMA.get(ambiente, ()): codigo, saida = _rodar("gsettings", "list-recursively", esquema) if codigo != 0: continue for linha in saida.splitlines(): partes = linha.split(None, 2) if len(partes) == 3 and any(_mesma(v, combinacao) for v in _lista_gvariant(partes[2])): return f"{esquema} {partes[1]}" return None # ------------------------------------------------------------ # Ambientes # ------------------------------------------------------------ def ambiente(): atual = (os.environ.get("XDG_CURRENT_DESKTOP") or os.environ.get("DESKTOP_SESSION") or "").upper() if "CINNAMON" in atual: return "cinnamon" if "MATE" in atual: return "mate" if "XFCE" in atual: return "xfce" if "KDE" in atual or "PLASMA" in atual: return "kde" if any(n in atual for n in ("GNOME", "UNITY", "BUDGIE", "ZORIN", "POP", "UBUNTU")): return "gnome" return None _NOMES_AMBIENTE = {"gnome": "GNOME", "cinnamon": "Cinnamon", "mate": "MATE", "xfce": "XFCE", "kde": "KDE"} def _gnome_itens(): caminhos = _lista_gvariant(_gsettings_get(GNOME_SCHEMA, "custom-keybindings")) itens = [] for caminho in caminhos: esquema = f"{GNOME_SCHEMA}.custom-keybinding:{caminho}" itens.append({ "caminho": caminho, "comando": (_lista_gvariant(_gsettings_get(esquema, "command")) or [""])[0], "atalho": (_lista_gvariant(_gsettings_get(esquema, "binding")) or [""])[0], }) return caminhos, itens def _registrar_gnome(combinacao, nome=NOME, marca=MARCA_COMANDO): if not shutil.which("gsettings") or not _esquema_existe(GNOME_SCHEMA): return False, t("atalho.sem_suporte") caminhos, itens = _gnome_itens() nosso = next((i for i in itens if _eh_nosso(i["comando"], marca)), None) outro = next((i for i in itens if i is not nosso and _mesma(i["atalho"], combinacao)), None) if outro: return False, t("atalho.conflito", acao=outro["comando"] or outro["caminho"]) sistema = _conflito_no_sistema("gnome", combinacao) if sistema: return False, t("atalho.conflito", acao=sistema) if nosso: caminho = nosso["caminho"] else: usados = set(caminhos) n = 0 while f"{GNOME_CAMINHO}custom{n}/" in usados: n += 1 caminho = f"{GNOME_CAMINHO}custom{n}/" esquema = f"{GNOME_SCHEMA}.custom-keybinding:{caminho}" ok = (_gsettings_set(esquema, "name", _gvariant_texto(nome)) and _gsettings_set(esquema, "command", _gvariant_texto(comando_barra_rapida(marca))) and _gsettings_set(esquema, "binding", _gvariant_texto(combinacao))) if ok and caminho not in caminhos: ok = _gsettings_set(GNOME_SCHEMA, "custom-keybindings", _gvariant_lista(caminhos + [caminho])) return ok, None if ok else t("atalho.falhou") def _remover_gnome(marca=MARCA_COMANDO): caminhos, itens = _gnome_itens() nossos = [i["caminho"] for i in itens if _eh_nosso(i["comando"], marca)] if not nossos: return True for caminho in nossos: _rodar("gsettings", "reset-recursively", f"{GNOME_SCHEMA}.custom-keybinding:{caminho}") return _gsettings_set(GNOME_SCHEMA, "custom-keybindings", _gvariant_lista([c for c in caminhos if c not in nossos])) def _cinnamon_itens(): nomes = _lista_gvariant(_gsettings_get(CINNAMON_SCHEMA, "custom-list")) itens = [] for nome in nomes: esquema = f"{CINNAMON_SCHEMA}.custom-keybinding:{CINNAMON_CAMINHO}{nome}/" itens.append({ "nome": nome, "comando": (_lista_gvariant(_gsettings_get(esquema, "command")) or [""])[0], "atalhos": _lista_gvariant(_gsettings_get(esquema, "binding")), }) return nomes, itens def _registrar_cinnamon(combinacao): if not shutil.which("gsettings") or not _esquema_existe(CINNAMON_SCHEMA): return False, t("atalho.sem_suporte") nomes, itens = _cinnamon_itens() nosso = next((i for i in itens if _eh_nosso(i["comando"])), None) outro = next((i for i in itens if i is not nosso and any(_mesma(a, combinacao) for a in i["atalhos"])), None) if outro: return False, t("atalho.conflito", acao=outro["comando"] or outro["nome"]) sistema = _conflito_no_sistema("cinnamon", combinacao) if sistema: return False, t("atalho.conflito", acao=sistema) if nosso: nome = nosso["nome"] else: n = 0 while f"custom{n}" in nomes: n += 1 nome = f"custom{n}" esquema = f"{CINNAMON_SCHEMA}.custom-keybinding:{CINNAMON_CAMINHO}{nome}/" ok = (_gsettings_set(esquema, "name", _gvariant_texto(NOME)) and _gsettings_set(esquema, "command", _gvariant_texto(comando_barra_rapida())) and _gsettings_set(esquema, "binding", _gvariant_lista([combinacao]))) if ok and nome not in nomes: ok = _gsettings_set(CINNAMON_SCHEMA, "custom-list", _gvariant_lista(nomes + [nome])) return ok, None if ok else t("atalho.falhou") def _remover_cinnamon(): nomes, itens = _cinnamon_itens() nossos = [i["nome"] for i in itens if _eh_nosso(i["comando"])] for nome in nossos: _rodar("gsettings", "reset-recursively", f"{CINNAMON_SCHEMA}.custom-keybinding:{CINNAMON_CAMINHO}{nome}/") return not nossos or _gsettings_set(CINNAMON_SCHEMA, "custom-list", _gvariant_lista([n for n in nomes if n not in nossos])) def _registrar_mate(combinacao): if not shutil.which("gsettings") or not _esquema_existe(MATE_COMANDOS): return False, t("atalho.sem_suporte") livre = None for n in range(1, 13): comando = (_lista_gvariant(_gsettings_get(MATE_COMANDOS, f"command-{n}")) or [""])[0] atalho = (_lista_gvariant(_gsettings_get(MATE_ATALHOS, f"run-command-{n}")) or [""])[0] if _eh_nosso(comando): livre = n break if _mesma(atalho, combinacao): return False, t("atalho.conflito", acao=comando or f"run-command-{n}") if livre is None and not comando and atalho in ("", "disabled"): livre = n if livre is None: return False, t("atalho.sem_espaco") sistema = _conflito_no_sistema("mate", combinacao) if sistema and f"run-command-{livre}" not in sistema: return False, t("atalho.conflito", acao=sistema) ok = (_gsettings_set(MATE_COMANDOS, f"command-{livre}", _gvariant_texto(comando_barra_rapida())) and _gsettings_set(MATE_ATALHOS, f"run-command-{livre}", _gvariant_texto(combinacao))) return ok, None if ok else t("atalho.falhou") def _remover_mate(): for n in range(1, 13): comando = (_lista_gvariant(_gsettings_get(MATE_COMANDOS, f"command-{n}")) or [""])[0] if _eh_nosso(comando): _rodar("gsettings", "reset", MATE_COMANDOS, f"command-{n}") _rodar("gsettings", "reset", MATE_ATALHOS, f"run-command-{n}") return True def _xfconf(*args): return _rodar("xfconf-query", "-c", XFCE_CANAL, *args) def _registrar_xfce(combinacao): if not shutil.which("xfconf-query"): return False, t("atalho.sem_suporte") for base in ("/commands/custom/", "/xfwm4/custom/"): codigo, atual = _xfconf("-p", base + combinacao) if codigo == 0 and atual and not _eh_nosso(atual): return False, t("atalho.conflito", acao=atual) _remover_xfce() ok = _xfconf("-p", "/commands/custom/" + combinacao, "-n", "-t", "string", "-s", comando_barra_rapida())[0] == 0 return ok, None if ok else t("atalho.falhou") def _remover_xfce(): codigo, saida = _xfconf("-l", "-v") if codigo != 0: return False for linha in saida.splitlines(): partes = linha.split(None, 1) if len(partes) == 2 and partes[0].startswith("/commands/custom/") and _eh_nosso(partes[1]): _xfconf("-p", partes[0], "-r") return True _REGISTRAR = {"gnome": _registrar_gnome, "cinnamon": _registrar_cinnamon, "mate": _registrar_mate, "xfce": _registrar_xfce} _REMOVER = {"gnome": _remover_gnome, "cinnamon": _remover_cinnamon, "mate": _remover_mate, "xfce": _remover_xfce} # ------------------------------------------------------------ # API # ------------------------------------------------------------ def registrar(combinacao=None): """Cadastra (ou troca) o atalho. Devolve (ok, mensagem).""" combinacao = normalizar_combinacao(combinacao) if combinacao else (i18n.ler_config().get("atalho") or PADRAO) if not combinacao: return False, t("atalho.invalido") qual = ambiente() funcao = _REGISTRAR.get(qual) if funcao is None: motivo = t("atalho.manual", ambiente=_NOMES_AMBIENTE.get(qual, qual or "?"), comando="opentars-gui --rapido") i18n.salvar_config(atalho=combinacao, atalho_registrado=False, atalho_tentado=True) return False, motivo ok, motivo = funcao(combinacao) i18n.salvar_config(atalho=combinacao, atalho_registrado=ok, atalho_tentado=True, atalho_ambiente=_NOMES_AMBIENTE.get(qual, qual)) if ok: return True, t("atalho.registrado", atalho=legivel(combinacao), ambiente=_NOMES_AMBIENTE.get(qual, qual)) return False, motivo def remover(): qual = ambiente() funcao = _REMOVER.get(qual) ok = funcao() if funcao else True i18n.salvar_config(atalho_registrado=False, atalho_tentado=True, atalho_desligado=True) return ok, t("atalho.removido") def estado(): config = i18n.ler_config() combinacao = config.get("atalho") or PADRAO info = {"combinacao": combinacao, "legivel": legivel(combinacao), "ambiente": config.get("atalho_ambiente"), "registrado": bool(config.get("atalho_registrado"))} if not info["registrado"]: qual = ambiente() if qual not in _REGISTRAR: info["motivo"] = t("atalho.manual", ambiente=_NOMES_AMBIENTE.get(qual, qual or "?"), comando="opentars-gui --rapido") return info def garantir_registrado(): """Na primeira vez que a janela abre: cadastra o atalho padrão (uma tentativa só; quem desligou com --atalho off não é incomodado).""" config = i18n.ler_config() if config.get("atalho_tentado") or config.get("atalho_desligado"): return None if os.environ.get("TARS_SEM_ATALHO") == "1": return None return registrar() def para_windows(combinacao): """'r' -> (modificadores, código da tecla) do RegisterHotKey do Windows; None se a tecla não tiver código.""" bits = {"primary": 2, "control": 2, "ctrl": 2, "alt": 1, "shift": 4, "super": 8} modificadores = sum(bits.get(m.lower(), 0) for m in re.findall(r"<(\w+)>", combinacao or "")) tecla = re.sub(r"<\w+>", "", combinacao or "") especiais = {"space": 0x20, "Return": 0x0D, "Tab": 0x09, "Escape": 0x1B} if tecla in especiais: codigo = especiais[tecla] elif len(tecla) == 1 and tecla.isascii() and tecla.isalnum(): # "ç" não tem código virtual próprio codigo = ord(tecla.upper()) elif re.fullmatch(r"F([1-9]|1[0-2])", tecla): codigo = 0x6F + int(tecla[1:]) else: return None return modificadores, codigo # códigos de tecla do Mac (kVK_ANSI_*) na ordem do teclado; § e \n ocupam o lugar das teclas que não são letra nem número _TECLAS_MAC = "asdfhgzxcv§bqweryt123465=97-80]ou[ip\nlj'k;\\,/nm." _FUNCAO_MAC = (0x7A, 0x78, 0x63, 0x76, 0x60, 0x61, 0x62, 0x64, 0x65, 0x6D, 0x67, 0x6F) def para_macos(combinacao): """'r' -> (modificadores, código da tecla) do RegisterEventHotKey do macOS; None se a tecla não tiver código. vira Control, não Command: o mesmo Ctrl+Alt+R dos outros sistemas (Command+Option+R já é de apps como o Safari).""" bits = {"primary": 0x1000, "control": 0x1000, "ctrl": 0x1000, "alt": 0x0800, "shift": 0x0200, "super": 0x0100} modificadores = sum(bits.get(m.lower(), 0) for m in re.findall(r"<(\w+)>", combinacao or "")) tecla = re.sub(r"<\w+>", "", combinacao or "") especiais = {"space": 0x31, "Return": 0x24, "Tab": 0x30, "Escape": 0x35} if tecla in especiais: codigo = especiais[tecla] elif len(tecla) == 1 and tecla.isascii() and tecla.isalnum(): codigo = _TECLAS_MAC.index(tecla.lower()) elif re.fullmatch(r"F([1-9]|1[0-2])", tecla): codigo = _FUNCAO_MAC[int(tecla[1:]) - 1] else: return None return modificadores, codigo def registrar_acoes(combinacao=None): """5.2: cadastra o atalho das ações de texto (GNOME; no Windows e no macOS o próprio openTARS escuta). Devolve (ok, mensagem).""" combinacao = normalizar_combinacao(combinacao) if combinacao else (i18n.ler_config().get("atalho_acoes") or ACOES_PADRAO) if not combinacao: return False, t("atalho.invalido") sistema = plataforma.sistema() if sistema in ("windows", "macos"): # quem escuta é a janela aberta (tars_qt/atalhos_windows.py e atalhos_macos.py) if not (para_windows if sistema == "windows" else para_macos)(combinacao): return False, t("atalho.invalido") i18n.salvar_config(atalho_acoes=combinacao, atalho_acoes_registrado=True) return True, t(f"atalho.acoes_{sistema}", atalho=legivel(combinacao)) qual = ambiente() if qual != "gnome": return False, t("atalho.manual", ambiente=_NOMES_AMBIENTE.get(qual, qual or "?"), comando="opentars-gui " + ACOES_MARCA) ok, motivo = _registrar_gnome(combinacao, ACOES_NOME, ACOES_MARCA) i18n.salvar_config(atalho_acoes=combinacao, atalho_acoes_registrado=ok) return (True, t("atalho.acoes_registrado", atalho=legivel(combinacao))) if ok else (False, motivo) def remover_acoes(): ok = _remover_gnome(ACOES_MARCA) if plataforma.sistema() == "linux" and ambiente() == "gnome" else True i18n.salvar_config(atalho_acoes_registrado=False) return ok, t("atalho.removido") def comando_acoes(argumento): """opentars --atalho-acoes [on|combinação|off]""" argumento = (argumento or "").strip() if not argumento or argumento.lower() in ("status", "estado"): config = i18n.ler_config() if config.get("atalho_acoes_registrado"): print(t("atalho.acoes_registrado", atalho=legivel(config.get("atalho_acoes") or ACOES_PADRAO))) else: print(t("atalho.acoes_como", atalho=legivel(ACOES_PADRAO))) return 0 if argumento.lower() in ("off", "desligar", "remover", "none", "no", "nao", "não"): ok, mensagem = remover_acoes() else: ok, mensagem = registrar_acoes(None if argumento.lower() in ("on", "ligar", "sim", "yes") else argumento) print(mensagem) return 0 if ok else 1 def comando(argumento): """opentars --atalho [combinação|off]""" argumento = (argumento or "").strip() if not argumento or argumento.lower() in ("status", "estado"): info = estado() if info["registrado"]: print(t("atalho.registrado", atalho=info["legivel"], ambiente=info.get("ambiente") or "?")) else: print(info.get("motivo") or t("atalho.nao_registrado")) print(t("atalho.como_registrar")) return 0 if argumento.lower() in ("off", "desligar", "remover", "none", "no", "nao", "não"): ok, mensagem = remover() print(mensagem) return 0 if ok else 1 ok, mensagem = registrar(argumento) print(mensagem) return 0 if ok else 1