File size: 12,146 Bytes
2695555
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
"""openTARS 2.9.2: mouse preciso.

Lógica pura (sem X, sem pyautogui) dos cliques e arrastos, testável sozinha:

- ancora():       "top-left", "canto superior esquerdo", "oben links"... -> ponto da tela
- caminho():      os pontos de um arrasto "de gente": sai devagar do lugar
                  (apps como o Chrome/Brave só entendem que é arrasto depois
                  de uns pixels com o botão apertado), acelera e freia no fim
- recorte_em_volta(): a região em volta de um ponto
- mudanca():      quanto a tela mudou entre duas capturas (o clique pegou?)
"""

import re
import unicodedata


# ------------------------------------------------------------
# Âncoras: lugares da tela por nome, em 5 idiomas
# ------------------------------------------------------------
MARGEM_ANCORA = 48  # px da borda: soltar colado na borda liga o "encaixe" de janela do GNOME/KDE

_VERTICAL = {
    "topo": ("top", "upper", "topo", "cima", "alto", "superior", "arriba", "haut", "oben", "obere", "oberen"),
    "base": ("bottom", "lower", "baixo", "inferior", "embaixo", "abajo", "bas", "unten", "untere", "unteren"),
}
_HORIZONTAL = {
    "esquerda": ("left", "esquerda", "esquerdo", "izquierda", "izquierdo", "gauche", "links", "linke", "linken"),
    "direita": ("right", "direita", "direito", "derecha", "derecho", "droite", "rechts", "rechte", "rechten"),
}
_CENTRO = ("center", "centre", "middle", "centro", "meio", "medio", "milieu", "mitte")


def _normalizar(texto):
    texto = unicodedata.normalize("NFKD", str(texto or "").lower())
    texto = "".join(c for c in texto if not unicodedata.combining(c))
    return re.findall(r"[a-z]+", texto)


def ancora(nome, largura, altura, margem=MARGEM_ANCORA):
    """(x, y) do lugar da tela chamado 'nome', ou None se não for um lugar.
    'top-left' -> perto do canto superior esquerdo; 'left' -> meio da
    borda esquerda; 'center' -> centro."""
    palavras = set(_normalizar(nome))
    if not palavras:
        return None
    vertical = next((k for k, v in _VERTICAL.items() if palavras & set(v)), None)
    horizontal = next((k for k, v in _HORIZONTAL.items() if palavras & set(v)), None)
    centro = bool(palavras & set(_CENTRO))
    if not (vertical or horizontal or centro):
        return None
    # "metade esquerda", "left half": o centro daquela metade
    metade = bool(palavras & {"half", "metade", "mitad", "moitie", "halfte", "haelfte"})
    margem = max(0, min(margem, largura // 4, altura // 4))
    if horizontal == "esquerda":
        x = largura // 4 if metade else margem
    elif horizontal == "direita":
        x = largura - largura // 4 if metade else largura - 1 - margem
    else:
        x = largura // 2
    if vertical == "topo":
        y = altura // 4 if metade else margem
    elif vertical == "base":
        y = altura - altura // 4 if metade else altura - 1 - margem
    else:
        y = altura // 2
    return x, y


# ------------------------------------------------------------
# Caminho do arrasto
# ------------------------------------------------------------
EMPURRAO_PX = 14      # o primeiro pedaço, devagar: vira "arrasto" no app
PASSOS_ARRASTO = 24


def caminho(x0, y0, x1, y1, passos=PASSOS_ARRASTO, empurrao=EMPURRAO_PX):
    """Pontos inteiros de (x0, y0) até (x1, y1), sem o de partida e
    terminando exatamente no destino. Os primeiros cobrem só 'empurrao' px
    (em 3 passos); o resto vai com aceleração e freada (ease-in-out)."""
    dx, dy = x1 - x0, y1 - y0
    distancia = (dx * dx + dy * dy) ** 0.5
    if distancia < 1:
        return [(int(x1), int(y1))]
    pontos = []
    inicio = min(1.0, empurrao / distancia)
    for i in (1, 2, 3):
        f = inicio * i / 3
        pontos.append((round(x0 + dx * f), round(y0 + dy * f)))
    resto = max(2, passos - 3)
    for i in range(1, resto + 1):
        s = i / resto
        suave = s * s * (3 - 2 * s)  # smoothstep
        f = inicio + (1 - inicio) * suave
        pontos.append((round(x0 + dx * f), round(y0 + dy * f)))
    pontos[-1] = (int(x1), int(y1))
    limpos = []
    for p in pontos:
        if not limpos or limpos[-1] != p:
            limpos.append(p)
    return limpos


def recorte_em_volta(x, y, raio, largura, altura):
    """(x0, y0, x1, y1) do quadrado de lado 2*raio em volta de (x, y),
    empurrado pra dentro da imagem (sem encolher, se couber)."""
    lado = 2 * raio
    x0 = min(max(0, x - raio), max(0, largura - lado))
    y0 = min(max(0, y - raio), max(0, altura - lado))
    return x0, y0, min(largura, x0 + lado), min(altura, y0 + lado)


SEMELHANCA_COR = 30  # soma R+G+B: até isso é "a mesma cor" (antisserrilhado à parte)


def _mesma_cor(a, b):
    return abs(a[0] - b[0]) + abs(a[1] - b[1]) + abs(a[2] - b[2]) <= SEMELHANCA_COR


def encaixar(imagem, x, y, raio=20):
    """Se o ponto caiu numa área lisa (errou o botão
    por pouco), vai pro centro do elemento INTEIRO mais perto (uma mancha
    de cor própria que cabe na região). Caiu em cima de algo: fica."""
    try:
        img = imagem.convert("RGB")
        if not (0 <= x < img.width and 0 <= y < img.height):
            return x, y
        x0, y0, x1, y1 = recorte_em_volta(x, y, 3, img.width, img.height)
        cor = img.getpixel((x, y))
        if not all(_mesma_cor(cor, img.getpixel((i, j))) for i in range(x0, x1) for j in range(y0, y1)):
            return x, y  # já está em cima de alguma coisa
        x0, y0, x1, y1 = recorte_em_volta(x, y, raio, img.width, img.height)
        regiao = img.crop((x0, y0, x1, y1))
        largura, altura = regiao.size
        px = regiao.load()
        visto = [[False] * largura for _ in range(altura)]
        lx, ly = x - x0, y - y0
        melhor = None
        for j in range(altura):
            for i in range(largura):
                if visto[j][i]:
                    continue
                semente = px[i, j]
                pilha, pontos = [(i, j)], []
                visto[j][i] = True
                while pilha:
                    a, b = pilha.pop()
                    pontos.append((a, b))
                    for na, nb in ((a + 1, b), (a - 1, b), (a, b + 1), (a, b - 1)):
                        if 0 <= na < largura and 0 <= nb < altura and not visto[nb][na] \
                                and _mesma_cor(px[na, nb], semente):
                            visto[nb][na] = True
                            pilha.append((na, nb))
                xs = [a for a, _ in pontos]
                ys = [b for _, b in pontos]
                bx0, bx1, by0, by1 = min(xs), max(xs), min(ys), max(ys)
                # o fundo onde o ponto caiu, pedaços de coisa maior (encostam
                # na borda) e ruído não são "o botão"
                if (bx0 <= lx <= bx1 and by0 <= ly <= by1 and (lx, ly) in set(pontos)) \
                        or bx0 == 0 or by0 == 0 or bx1 == largura - 1 or by1 == altura - 1 \
                        or len(pontos) < 12:
                    continue
                dx = max(bx0 - lx, 0, lx - bx1)
                dy = max(by0 - ly, 0, ly - by1)
                d = dx * dx + dy * dy
                if melhor is None or d < melhor[0]:
                    melhor = (d, (bx0 + bx1) / 2, (by0 + by1) / 2)
        if melhor is None:
            return x, y
        return round(x0 + melhor[1]), round(y0 + melhor[2])
    except Exception:
        return x, y


# ------------------------------------------------------------
# A tela mudou?
# ------------------------------------------------------------
LARGURA_COMPARACAO = 320
LIMIAR_PIXEL = 40        # diferença R+G+B pra contar como pixel mudado
FRACAO_MUDOU = 0.002     # 0,2% dos pixels: uma aba fechando já passa disso


def mudanca(antes, depois):
    """Fração (0–1) dos pixels que mudaram entre duas capturas do mesmo
    lugar. Compara em tamanho pequeno: rápido e ignora ruído de 1 px."""
    if antes is None or depois is None or antes.size != depois.size:
        return None
    largura = min(LARGURA_COMPARACAO, antes.width)
    altura = max(1, round(antes.height * largura / antes.width))
    a = antes.convert("RGB").resize((largura, altura))
    b = depois.convert("RGB").resize((largura, altura))
    pa, pb = a.load(), b.load()
    mudados = 0
    for j in range(altura):
        for i in range(largura):
            ca, cb = pa[i, j], pb[i, j]
            if abs(ca[0] - cb[0]) + abs(ca[1] - cb[1]) + abs(ca[2] - cb[2]) > LIMIAR_PIXEL:
                mudados += 1
    return mudados / (largura * altura)


def mudou(fracao):
    return fracao is not None and fracao >= FRACAO_MUDOU


# ------------------------------------------------------------
# 3.0 Miller: mover JANELA (não arrastar o que está escrito nela)
# ------------------------------------------------------------
# "arraste a janela do openTARS pro topo esquerdo": antes o arrasto ia
# até o TEXTO "openTARS" dentro da janela. Mover janela é com o
# gerenciador de janelas (EWMH), que sabe onde fica a borda e a barra.
_PALAVRAS_JANELA = {"janela", "window", "ventana", "fenetre", "fenster", "titlebar", "barra de titulo",
                    "title bar", "barre de titre", "titelleiste", "barra de titulo"}
_PALAVRAS_ABA = {"aba", "tab", "guia", "pestana", "onglet", "reiter", "tabs", "abas"}
_RE_MAXIMIZAR = {"maximize", "maximizar", "maximizada", "maximiser", "maximieren", "tela cheia", "full screen",
                 "fullscreen", "pantalla completa", "plein ecran", "vollbild", "inteira"}
_RE_RESTAURAR = {"restore", "restaurar", "restaure", "wiederherstellen", "normal", "unmaximize"}


def fala_de_janela(texto):
    """'janela do openTARS', 'barra de título', 'the firefox window' (mas não 'aba do YouTube')."""
    n = " ".join(_normalizar(texto))
    palavras = set(n.split())
    if palavras & _PALAVRAS_ABA:
        return False
    return bool(palavras & _PALAVRAS_JANELA) or any(p in n for p in _PALAVRAS_JANELA if " " in p)


def sem_palavras_de_janela(texto):
    """'a janela do openTARS' -> 'openTARS'."""
    fora = {"a", "o", "the", "la", "le", "die", "der", "da", "do", "de", "of", "del", "du", "des"}
    fora |= {p for p in _PALAVRAS_JANELA if " " not in p}
    fora |= {"barra", "titulo", "title", "bar", "barre", "titre", "titelleiste", "sua", "your", "minha", "my"}
    return " ".join(p for p in str(texto or "").split() if " ".join(_normalizar(p)) not in fora).strip()


def lugar_da_janela(lugar, area, tamanho=None):
    """Onde pôr a janela: (x, y, largura, altura) do retângulo EXTERNO (com a
    borda), largura/altura None = não muda o tamanho; ou 'maximizar' /
    'restaurar'; ou None se 'lugar' não é um lugar conhecido.

    area = (x, y, largura, altura) da área útil (sem painéis, _NET_WORKAREA);
    tamanho = (largura, altura) da janela por fora, pra encostar nos cantos
    da direita/de baixo e centralizar."""
    palavras = set(_normalizar(lugar))
    n = " ".join(_normalizar(lugar))
    if palavras & _RE_MAXIMIZAR or any(p in n for p in _RE_MAXIMIZAR if " " in p):
        return "maximizar"
    if palavras & _RE_RESTAURAR:
        return "restaurar"
    ax, ay, al, aa = area
    vertical = next((k for k, v in _VERTICAL.items() if palavras & set(v)), None)
    horizontal = next((k for k, v in _HORIZONTAL.items() if palavras & set(v)), None)
    centro = bool(palavras & set(_CENTRO))
    metade = bool(palavras & {"half", "metade", "mitad", "moitie", "halfte", "haelfte"})
    if not (vertical or horizontal or centro):
        return None
    if metade:
        if horizontal and not vertical:
            return (ax if horizontal == "esquerda" else ax + al // 2, ay, al // 2, aa)
        if vertical and not horizontal:
            return (ax, ay if vertical == "topo" else ay + aa // 2, al, aa // 2)
    largura, altura = tamanho or (al // 2, aa // 2)
    largura, altura = min(largura, al), min(altura, aa)
    if horizontal == "esquerda":
        x = ax
    elif horizontal == "direita":
        x = ax + al - largura
    else:
        x = ax + (al - largura) // 2
    if vertical == "topo":
        y = ay
    elif vertical == "base":
        y = ay + aa - altura
    else:
        y = ay + (aa - altura) // 2
    return (x, y, None, None)