openTARS / app /tars_atalho.py
ProjectOpenTARS4's picture
Mirror of openTARS 5.2 Ranger (revision 2026110101)
2695555 verified
Raw History Blame Contribute Delete
22.6 kB
"""
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 = "<Primary><Alt>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 = "<Primary><Alt>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": "<Primary>", "control": "<Primary>", "primary": "<Primary>", "strg": "<Primary>",
"alt": "<Alt>", "shift": "<Shift>", "super": "<Super>", "win": "<Super>", "meta": "<Super>",
"mod4": "<Super>",
}
# ------------------------------------------------------------
# Combinação de teclas
# ------------------------------------------------------------
def normalizar_combinacao(texto):
""""Ctrl+Alt+Space", "<Control><Alt>space", "super+t" -> formato GTK
("<Primary><Alt>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 = ["<Primary>", "<Shift>", "<Alt>", "<Super>"]
return "".join(sorted(saida, key=ordem.index)) + tecla
def legivel(combinacao):
""""<Primary><Alt>space" -> "Ctrl+Alt+Espaço" (no idioma escolhido)."""
nomes = {"<Primary>": "Ctrl", "<Control>": "Ctrl", "<Alt>": "Alt", "<Shift>": "Shift", "<Super>": "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):
"""'<Primary><Alt>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):
"""'<Primary><Alt>r' -> (modificadores, código da tecla) do RegisterEventHotKey do macOS; None se a tecla não tiver código.
<Primary> 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