Metadata-Version: 2.5
Name: onerom-desktop
Version: 1.15.1
Summary: Runtime de automacao desktop da plataforma ONEROM (UIA, OCR, imagem, coordenadas e SAP GUI)
Project-URL: Homepage, https://onerom.dev.br
Author: ONEROM Team
License: MIT
License-File: LICENSE
Keywords: automation,desktop,ocr,onerom,rpa,sap,uia
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: Microsoft :: Windows
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.9
Requires-Dist: mss>=9.0.0
Requires-Dist: pillow>=10.0.0
Requires-Dist: psutil>=5.9.0
Requires-Dist: pyautogui>=0.9.54
Requires-Dist: pyperclip>=1.9.0
Provides-Extra: all
Requires-Dist: comtypes>=1.4.16; (sys_platform == 'win32') and extra == 'all'
Requires-Dist: numpy>=1.24; extra == 'all'
Requires-Dist: opencv-python-headless<5,>=4.9.0; extra == 'all'
Requires-Dist: pytesseract>=0.3.10; extra == 'all'
Requires-Dist: pywin32>=306; (sys_platform == 'win32') and extra == 'all'
Requires-Dist: pywinauto>=0.6.9; (sys_platform == 'win32') and extra == 'all'
Provides-Extra: backend
Requires-Dist: comtypes>=1.4.16; (sys_platform == 'win32') and extra == 'backend'
Requires-Dist: pywin32>=306; (sys_platform == 'win32') and extra == 'backend'
Requires-Dist: pywinauto>=0.6.9; (sys_platform == 'win32') and extra == 'backend'
Provides-Extra: image
Requires-Dist: numpy>=1.24; extra == 'image'
Requires-Dist: opencv-python-headless<5,>=4.9.0; extra == 'image'
Provides-Extra: ocr
Requires-Dist: numpy>=1.24; extra == 'ocr'
Requires-Dist: opencv-python-headless<5,>=4.9.0; extra == 'ocr'
Requires-Dist: pytesseract>=0.3.10; extra == 'ocr'
Provides-Extra: sap
Requires-Dist: pywin32>=306; (sys_platform == 'win32') and extra == 'sap'
Description-Content-Type: text/markdown

# onerom-desktop

Runtime de automação desktop da plataforma ONEROM.

É a biblioteca que os bots gerados pelo **ONEROM Inspector** importam para clicar,
preencher e ler telas — de aplicações Windows nativas a SAP GUI, passando por
janelas que não expõem nenhuma árvore de automação.

```bash
pip install "onerom-desktop[all]"
```

## Uma classe, cinco estratégias

```python
from onerom_desktop import App

app = App(strategy="backend", automation_id="btnLogin", window_title="Login")
app.wait_visible()
app.click()
```

O que você passa no construtor vira padrão para todas as ações; o que passa na
ação vale só para aquela chamada. É isso que deixa o script curto sem esconder o
que ele está mirando.

| `strategy=` | Como localiza | Quando usar |
|---|---|---|
| `backend` | UIAutomation via pywinauto | **Padrão.** Sobrevive a janela movida, resolução diferente e troca de tema |
| `ocr` | Texto lido da tela (Tesseract) | Citrix, RDP, canvas Java/Delphi — quando não há árvore de automação |
| `image` | Template matching (OpenCV + ORB) | Ícones e controles desenhados, sem identidade nem rótulo |
| `coordinate` | Ponto fixo na tela | Último recurso: não verifica nada |
| `sap` | SAP GUI Scripting (COM) | Sempre, quando o alvo é SAP |
| `auto` | `backend` → `ocr` → `image` → `coordinate` | Degrada sozinho quando a aplicação muda |

```python
app.fill(strategy="ocr", text="Usuário", value="admin")
app.click(strategy="image", image="botao.png", confidence=0.9)
app.press(strategy="sap", sap_id="wnd[0]/tbar[0]/btn[0]")
app.click(strategy="auto")
```

## Âncora visual e ações relativas

Muito campo não tem identidade nenhuma — sem `automation_id`, sem texto próprio,
sem pixels distintivos — mas fica sempre ao lado de algo que tem: um rótulo, um
ícone, um cabeçalho de coluna. Ancore no que dá para ver e aja por deslocamento:

```python
app.find(strategy="image", image="rotulo_usuario.png")   # acha uma vez
app.fill_relative(140, 0, value="admin")                 # campo à direita
app.fill_relative(140, 34, value="segredo")              # o de baixo
app.click_relative(140, 70)                              # o botão
```

O deslocamento parte do **centro** da âncora, então sobrevive à janela mudar de
lugar — ao contrário de uma coordenada absoluta. E a busca por imagem acontece
**uma vez** para todos os campos ao redor, em vez de varrer a tela a cada um.

Sem âncora, dá para deslocar direto na ação:

```python
app.click(strategy="image", image="rotulo.png", offset_x=140, offset_y=0)
```

Disponíveis: `click_relative`, `double_click_relative`, `right_click_relative`,
`hover_relative`, `fill_relative`, `type_relative`. E `app.anchor` devolve a
região atual, ou `None` se ainda não houve `find()`.

### Enum ou texto, tanto faz

As opções que são "uma de N" têm enum, e ele **vale como texto** — nenhum bot
existente precisa mudar:

```python
from onerom_desktop import App, Strategy, TypeMode, ClearMode

app.click(strategy=Strategy.IMAGE, image="botao.png")
app.write("senha", mode=TypeMode.SCANCODE)
app.fill(image="campo.png", value="x", clear_mode=ClearMode.BACKSPACE)
```

| enum | valores |
|---|---|
| `Strategy` | `BACKEND` · `OCR` · `IMAGE` · `COORDINATE` · `SAP` · `AUTO` |
| `TypeMode` | `PASTE` · `KEYS` · `SCANCODE` |
| `ClearMode` | `SELECT_ALL` · `SELECT_TO_START` · `BACKSPACE` · `NONE` |

O ganho é o autocomplete e o erro de digitação virar erro do editor em vez de
exceção em execução. `Strategy.IMAGE == "image"` é verdadeiro, e as listas de
texto (`TYPE_MODES`, `CLEAR_MODES`, `KNOWN_STRATEGIES`) são derivadas dos enums
— não há duas fontes para divergirem.

**Cada membro se explica em execução.** Um comentário acima do membro só
aparece em gerador de documentação; a descrição vai no próprio membro:

```python
Strategy.IMAGE.description   # o que essa estratégia faz
Strategy.IMAGE.params        # ('image', 'confidence')
TypeMode.SCANCODE.description
print(Strategy.catalog())    # todas, com o que fazem e o que aceitam
```

`.params` é conferido por teste contra os parâmetros que a lib de fato lê — se
alguém renomear um, o teste quebra em vez de a documentação mentir.

## Ações

`click` · `double_click` · `triple_click` · `right_click` · `hover` · `fill` ·
`click_and_fill` · `double_click_and_fill` · `triple_click_and_fill` ·
`type_text` · `get_text` · `wait_visible` · `wait_not_visible` · `exists` ·
`find` · `find_all` · `scroll` · `drag_to` · `screenshot`

Teclado: `press_key` · `hotkey` · `write` · `copy_text` · `cut` ·
`select_all` · `clear` · `undo` · `press_enter` · `press_tab` ·
`press_shift_tab` · `press_esc` · `press_down` · `press_up`.

Janela e processo: `start` · `connect` · `activate` · `maximize` · `minimize` ·
`restore` · `move_window` · `window_region` · `window_exists` · `wait_window` ·
`close_window` · `kill`.

Diálogos e hierarquia: `wait_dialog` · `dialog` · `dialogs` · `owner_window` ·
`parent_window` · `child_windows` · `window_title` · `window_class` · `use_window`.

Para SAP, também: `set_text` · `press` · `submit` · `grid_read` · `grid_rows` ·
`grid_columns` · `grid_cell` · `grid_set_cell`.

```python
if app.exists(strategy="backend", automation_id="dlgErro", window_title="Erro"):
    app.click(automation_id="btnFechar")

total = app.get_text(strategy="ocr", x=800, y=440, width=160, height=28)
app.screenshot("evidencia.png")
```


### Campo de senha que diz "senha incorreta"

Sintoma: o robô preenche, a tela mostra os pontinhos, o sistema recusa — e a
mesma senha digitada à mão entra. Não é a senha; é **o que chega ao aplicativo**.

Muito campo de senha de sistema legado monta o valor a partir dos **eventos de
tecla** (`OnKeyDown`/`OnKeyPress`, ou um gancho anti-keylogger) em vez de ler o
texto do campo. Nesses:

| modo | o que o aplicativo recebe |
|---|---|
| `paste` | Ctrl+V — **nenhuma tecla** |
| `keys` | o caractere, com código de tecla virtual **zero** |
| `scancode` | tecla de verdade: código virtual, varredura e modificadores |

```python
app.write("5s3@dF%gRwD#22", mode="scancode", interval=0.05)
app.click_and_fill(image="senha.png", value="...", type_mode="scancode")
```

`scancode` depende do layout de teclado ativo — um caractere que o layout não
produz com uma tecla só faz a lib cair para `keys`, **avisando**, porque nesse
caso o código de tecla que motivou o pedido não vai junto.

### Quando o campo não é limpo

`fill` e `click_and_fill` clicam, dão um Ctrl+A e escrevem por cima. Quando o
valor antigo continua lá depois disso — ou aparece grudado no novo — é porque o
Ctrl+A não selecionou nada. Acontece em dois casos comuns: o aplicativo usa esse
atalho para outra coisa (selecionar todos os registros de uma grade, em sistemas
Delphi, Java e SAP), ou o campo ainda não tinha aceitado o foco de teclado.

Há duas saídas, e a segunda é a mais forte:

```python
# End + Shift+Home, sem usar o Ctrl+A
app.click_and_fill(image="campo.png", value="novo", clear_mode="select_to_start")

# apaga tecla a tecla, sem usar atalho nenhum
app.click_and_fill(image="campo.png", value="novo", clear_mode="backspace")

# seleciona a linha inteira com o MOUSE: não depende do teclado chegar ao campo
app.triple_click_and_fill(image="campo.png", value="novo")
```

`double_click_and_fill` existe para o outro caso: trocar **uma palavra** dentro
de um campo que tem mais coisas. Num campo com `Joao Silva`, o clique duplo pega
só `Joao`; para trocar o campo inteiro é o triplo.

`clear_mode` aceita `select_all` (padrão), `select_to_start` (End + Shift+Home),
`backspace` e `none`. Um valor desconhecido levanta erro na hora, em vez de
deixar o campo sem limpar.

### Digitar em vez de colar

Por padrão o valor é **colado**: uma mensagem só, independente do tamanho, e sem
depender do layout de teclado. Mas colar não acorda quem *reage* à digitação:

```python
app.write("São Paulo", mode="keys", interval=0.05)
app.click_and_fill(image="cidade.png", value="São Paulo", type_mode="keys")

# vale para as ações relativas também
app.find(image="rotulo.png")
app.type_relative(140, 0, value="São Paulo", type_mode="keys", type_interval=0.05)
```

Repare no nome: em `write()` os parâmetros são `mode`/`interval`; nas **ações**
são `type_mode`/`type_interval`, porque ali convivem com `clear_mode` e com os
parâmetros do localizador. Chutar o nome errado **levanta erro** e diz qual é o
certo — antes ele era ignorado em silêncio, e o robô digitava a toda velocidade
sem ninguém avisar.

`mode="keys"` manda uma tecla por caractere. É o que serve para campo com
autocomplete, busca-enquanto-digita e máscara que reformata a cada caractere —
e é o único caminho num campo que recusa colagem. Como bônus, não encosta no
clipboard do operador.

Acentos sobrevivem: cada caractere vai pelo **código**, não pela tecla, então o
layout ativo não importa. Isso é feito com `SendInput`/`KEYEVENTF_UNICODE`, e
não com o `typewrite` do pyautogui — medido, `"Coração, ação — não"` digitado
pelo `typewrite` sai `"Corao, ao  no"`, com todo caractere acentuado perdido em
silêncio.

### O primeiro parâmetro posicional é a estratégia

Isto **não** faz o que parece:

```python
app.click("botao.png")     # vira strategy="botao.png" -> erro
```

Toda ação tem a assinatura `acao(strategy=None, **localizador)`. O localizador
vai **por nome**:

```python
app.click(strategy="image", image="botao.png")
app.click_image("botao.png")               # o mesmo, mais curto
app.click(automation_id="btnLogin")        # usa a estratégia padrão do App
```

A mensagem de erro reconhece o engano e diz o que você quis fazer, inclusive
sugerindo a estratégia certa quando é só um erro de digitação.

### A estratégia é deduzida do localizador

Você não precisa repetir `strategy=` quando o parâmetro já diz tudo:

```python
app = App()                       # padrão: backend
app.click(image="botao.png")      # usa IMAGEM — image= só existe lá
app.click(text="Salvar")          # usa OCR
app.click(x=500, y=300)           # usa COORDENADA
app.click(automation_id="btn")    # usa BACKEND
```

A dedução é conservadora e **não** age quando:

- você passou `strategy=` na chamada — dizer vence adivinhar;
- a estratégia atual já dá conta dos parâmetros;
- há pistas de duas estratégias ao mesmo tempo (`image=` e `text=` juntos);
- a estratégia é `auto`, ou uma registrada por você — não sabendo quais
  parâmetros ela usa, trocar seria sequestrar a sua extensão.

Ela vale **por chamada**: o `App` não é alterado por baixo dos panos.

### A estratégia de uma chamada não fica guardada

`find(strategy="image", ...)` não passa a valer para as ações seguintes — cada
chamada resolve a sua. Para mudar o padrão de todas, use o construtor:

```python
app = App(strategy="image")
```

E se o que você quer é agir sobre o que o `find` encontrou, o caminho é a ação
relativa com deslocamento zero:

```python
app.find(image="rotulo.png")
app.click_relative(0, 0)          # clica no que foi encontrado
app.type_relative(140, 0, value="admin")   # e no campo ao lado dele
```

## Referência de parâmetros

Toda ação tem a forma `acao(strategy=None, **parametros)`. Esta é a lista
completa do que a lib lê.

Você não precisa vir aqui: **a mesma lista está na docstring de cada ação**, só
com os grupos que cabem àquela ação. `help(app.click)` responde sem sair do
editor. A fonte é uma só (`paramdocs.py`) e um teste a confere contra os
parâmetros que o código de fato busca — nos dois sentidos, para não faltar nem
sobrar.

### Localizar o elemento

Qual grupo usar depende da estratégia — e a estratégia é **deduzida** do próprio
parâmetro quando você não a diz.

| estratégia | parâmetro | obrigatório | o que é |
|---|---|---|---|
| `backend` | `automation_id` (ou `auto_id`) | um dos quatro | Id de automação do controle |
| | `name` (ou `title`) | | Texto/rótulo do controle |
| | `control_type` | | `Button`, `Edit`, `ComboBox`… |
| | `class_name` | | Classe Win32 do controle |
| | `backend` | não | Motor do pywinauto. Padrão `uia` |
| `image` | `image` | **sim** | Caminho do PNG de referência |
| | `confidence` | não | Similaridade mínima, 0–1. Padrão `0.85` |
| `ocr` | `text` | **sim** | Texto a procurar na tela |
| | `x` `y` `width` `height` | não | Recorta a área antes de ler |
| `coordinate` | `x` `y` | **sim** | Ponto na tela, em pixels **físicos** |
| | `width` `height` (ou `w` `h`) | não | Região, para `get_text` |
| `sap` | `sap_id` (ou `id`, `locator`) | **sim** | Id do componente, ou caminho completo |
| | `connection` `session` | não | Índice da conexão e da sessão |
| | `sap_gui` `sap_path` `connection_name` | não | Abrir o SAP quando não estiver aberto |

### Apontar a janela

Valem em qualquer estratégia. Sem eles, a busca do `backend` varre todas as
aplicações abertas e pode achar a janela errada.

| parâmetro | o que é |
|---|---|
| `window_title` | Trecho do título da janela |
| `process` (ou `process_name`) | Nome do executável, ex. `notepad.exe` |
| `expected_x` `expected_y` | Onde o elemento estava na captura. Só desempata entre iguais — nunca é usado como alvo do clique |

### Ajustar a ação

| parâmetro | ações | o que é |
|---|---|---|
| `offset_x` `offset_y` | todas as de ponteiro | Desloca do que foi encontrado. Clique no campo ao lado do rótulo |
| `clear_mode` | `fill`, `click_and_fill` | Como esvaziar antes de escrever. Veja `ClearMode` |
| `type_mode` | as de escrita | Colar ou digitar. Veja `TypeMode` |
| `type_interval` | as de escrita | Pausa entre caracteres, em segundos |
| `amount` | `scroll` | Quantas notches da roda. Padrão `3` |
| `direction` | `scroll` | `down` `up` `left` `right` (ou em português) |
| `to_x` `to_y` | `drag_to` | Destino absoluto |
| `dx` `dy` | `drag_to` | Destino relativo à origem |
| `duration` | `drag_to` | Duração do arrasto, em segundos |
| `max_results` | `find_all` | Teto de elementos devolvidos |
| `timeout` | as de espera | Segundos até desistir |

> `x`/`y` acumulam dois papéis: **alvo** na estratégia de coordenada e **posição
> esperada** no backend. Por isso a dedução de estratégia só os considera em par
> e quando não há outro localizador.


## Janela e processo

Antes de clicar em qualquer coisa é preciso que a janela certa esteja aberta e na
frente. Um bot que não traz a janela para frente clica no que estiver por cima.

```python
app = App(process="sistema.exe", window_title="Login")

app.start(r"C:\Program Files\Sistema\sistema.exe")   # abre e espera a janela
app.activate()                                        # traz para frente e foca
app.maximize()
...
app.close_window()                                    # pede para fechar
```

| Método | O que faz |
|---|---|
| `start(command, args=, cwd=, wait=)` | Abre o aplicativo e espera a janela. Devolve o pid |
| `connect(timeout=)` | Anexa a um aplicativo já aberto. Devolve o handle |
| `activate()` | Traz para frente e dá foco de teclado |
| `maximize()` · `minimize()` · `restore()` | Estado da janela |
| `move_window(x, y, width=, height=)` | Move e redimensiona. Tamanho 0 mantém o atual |
| `window_region()` | O retângulo da janela, como `Region` |
| `window_exists()` | Se há uma janela correspondente aberta agora |
| `wait_window(timeout=)` | Bloqueia até a janela aparecer |
| `close_window(timeout=)` | Pede para fechar; **devolve se realmente fechou** |
| `kill(timeout=)` | Derruba o processo. Nada é salvo |
| `app.hwnd` · `app.pid` | A última janela alcançada e o processo iniciado |

Tudo isso é `ctypes` sobre o `user32`, **sem pywinauto** — logo funciona na
instalação base, inclusive para bots de `image` e `ocr`, que são justamente os que
mais precisam da janela na frente por clicarem em pixels reais.

### Três detalhes que não são óbvios

**`close_window()` devolve um booleano e você tem de olhar.** Um aplicativo que
responde com "deseja salvar?" não fechou — e isso é um desfecho normal, não uma
falha:

```python
if not app.close_window(timeout=5):
    app.click(strategy="image", image="nao_salvar.png")
```

**`activate()` levanta em vez de seguir em frente.** O Windows recusa foco a quem
não o tem, e o quanto ele recusa é configuração de máquina (`ForegroundLockTimeout`
pode estar em "nunca"). A lib escala sozinha — chamada direta, depois
`AttachThreadInput`, depois um ciclo minimizar/restaurar — mas se nada funcionou ela
levanta `WindowActivationError`, porque continuar mandaria o próximo clique para a
janela errada.

**O handle é cacheado, e é de propósito.** Resolver pelo título a cada chamada
quebra no fluxo mais comum de software corporativo: o título muda depois do login
("Login" vira "Sistema - Jane Doe"). O handle não muda. `app.hwnd` é a última janela
alcançada; passar `process=`/`window_title=` numa chamada sempre resolve de novo.

## Teclado

Nem tudo se resolve clicando. Enter envia formulário, Tab anda entre campos, F5
recarrega uma lista — e não existe equivalente de colagem para nenhum deles.

```python
app.press_key("enter")
app.press_key("tab", times=3)
app.hotkey("ctrl", "s")        # ou app.press_key("ctrl+s")
app.write("texto")             # escreve no campo em foco, sem clicar em nada
```

### Atalhos diretos

Para o punhado que um bot manda o dia inteiro, há nome próprio — o ganho é o
autocompletar, não ter que lembrar o nome da tecla em texto:

```python
app.press_enter()      app.press_tab(times=3)     app.press_shift_tab()
app.press_esc()        app.press_down(times=2)    app.press_up()
app.select_all()       app.clear()                app.undo()
texto = app.cut()      texto = app.copy_text()
```

`clear()` esvazia o campo em foco sem escrever nada — dois passos viraram um.
`cut()` e `copy_text()` **devolvem o texto**, que é o que separa isso de um
"recorta e reza".

A lista é fechada de propósito. Um método por tecla F, por seta e por
combinação de letra é ruído: o autocompletar deixa de ajudar e você volta pro
`press_key("...")` de qualquer jeito. Não há `control_c` nem `control_v`, e a
ausência é deliberada — quem quer o texto usa `copy_text()`, quem quer escrever
usa `write()`, que já cola e ainda devolve o clipboard do operador ao que era.

### Repetir com folga

`times=` repete; `interval=` é a espera **entre** as repetições, em segundos:

```python
app.press_enter(times=5, interval=1.0)
app.press_down(times=10, interval=0.2)
```

Serve para o alvo que processa cada tecla antes da próxima — uma lista que
recarrega a cada seta, um formulário que valida a cada Enter. Sem folga as
repetições chegam mais rápido do que a aplicação consome, e ela perde algumas
sem reclamar.

Nomes em português são de primeira classe: `"baixo"`, `"cima"`, `"espaco"`,
`"fim"`, `"deletar"`, `"controle"`. Uma tecla desconhecida **levanta** e lista as
válidas — mandar nada pareceria que a aplicação ignorou.

Isso age em quem está com o foco, de propósito. "Manda Enter naquele botão" são
duas ideias — focar e apertar — e juntá-las esconde qual das duas falhou.

`app.write(...)` é a mesma ideia para texto: escreve onde o foco já está. É o
passo depois de um Tab, de um atalho que abriu um campo, ou de um clique que
você já deu. Ele **acrescenta**, não limpa — quem substitui é `fill` /
`triple_click_and_fill`, que sabem selecionar o valor antigo antes.

Quando você tem um localizador, `type_text` faz as duas coisas (clica e digita):

```python
app.press_key("tab")
app.write("Maria")                             # no foco atual

app.type_text(image="campo.png", value="Maria")  # clica no campo e digita
```

`app.copy_text()` dá Ctrl+C e devolve o que caiu no clipboard: a saída para um
controle que mostra texto mas não expõe valor nenhum ao UIAutomation.

### Sem `App`: só escrever

Teclado e mouse são módulos por si só. Agem sobre o que estiver **em foco**, e
não precisam de janela conectada, de `start()`, de `connect()` nem de um `App`:

```python
from onerom_desktop import keyboard, pointer

pointer.click(500, 300)
keyboard.type_value("São Paulo", mode="keys", interval=0.05)
keyboard.press("enter")

keyboard.clear_field()                    # Ctrl+A (ou clear_field("backspace"))
keyboard.replace_value("novo", mode="keys")
texto = keyboard.read_clipboard()
```

Serve para um passo dentro de um fluxo maior que já tem a janela certa na
frente, e para um script curto que não quer montar um `App` só para digitar.
`app.write(...)` faz o mesmo pelo `App`, quando você já tem um.

## Rolagem e arrasto

```python
app.scroll(5, "down", automation_id="lstPedidos")
app.scroll(3, "up")                                  # onde o ponteiro já está
app.drag_to(900, 400, strategy="image", image="card.png")
app.find(strategy="image", image="item.png")
app.drag_relative(0, 240)
```

O destino do arrasto é `to_x`/`to_y` na assinatura, não `x`/`y` — estes já
significam *onde está a origem* para `strategy="coordinate"`, e um arrasto
precisa das duas pontas.

## Esperar aparecer, e esperar sumir

```python
app.wait_visible(automation_id="btnSalvar", timeout=20)
app.wait_not_visible(strategy="image", image="spinner.png", timeout=60)
```

`wait_not_visible` é a metade que faz um bot progredir: o spinner, o overlay de
"aguarde", o modal que precisa fechar antes do próximo passo. Sem ela todo bot
escreve o mesmo laço na mão e esquece o timeout.

## `find_all` — percorrer uma lista

```python
for linha in app.find_all(strategy="image", image="checkbox_vazio.png"):
    app.click(strategy="coordinate", x=linha.center_x, y=linha.center_y)
```

Funciona nas quatro estratégias: o `backend` enumera os descendentes que casam,
o `image` devolve todas as ocorrências do template, o `ocr` todas as linhas com
o texto. Lista vazia é resposta, não falha.

Uma diferença deliberada em relação ao `find()`: a enumeração **não** afrouxa os
critérios passo a passo. Afrouxar existe para resgatar uma captura que
envelheceu; numa lista, isso misturaria as linhas de um grid com controles sem
relação que apenas compartilham um pedaço do nome — e quem recebe as regiões não
tem como perceber.

## Resiliência e evidência

```python
app = App(
    retries=2,                       # tentativas extras quando não acha
    retry_delay=0.5,
    screenshot_on_error="evidencias" # PNG da tela quando um passo falha de vez
)
```

`retries` é **0 por padrão**: ligar sozinho mudaria a duração de todo passo de
todo bot que já existe. Só falhas do tipo "não estava lá desta vez" são
repetidas — dependência faltando e localizador inválido nunca, porque repetir
esses só enterra a mensagem real sob N cópias iguais. As esperas também nunca
são repetidas: elas já têm timeout próprio, e repetir multiplicaria o que você
pediu.

O screenshot é o que salva a investigação: quando alguém lê o log, o modal
inesperado que derrubou o bot já sumiu da tela. O caminho fica em
`app.last_error_screenshot`.

## Grids do SAP

A coisa que um bot de SAP faz e que **nenhuma** biblioteca genérica de
UIAutomation consegue. O SAP desenha os próprios grids, então nem pywinauto nem
FlaUI enxergam uma linha sequer — a API de scripting é a única porta.

```python
for linha in app.grid_read("wnd[0]/usr/cntlGRID1/shellcont/shell"):
    print(linha["Documento"], linha["Valor"])

app.grid_cell("wnd[0]/usr/cntlGRID1/shellcont/shell", row=0, column="DOCNUM")
app.grid_set_cell(..., row=3, column="QTD", value="10")
```

Atende as duas famílias sem você precisar saber qual a transação usa: o
`GuiGridView` (ALV, colunas por nome) e o `GuiTableControl` (colunas por índice).

**O detalhe que importa:** um `GuiTableControl` só materializa as linhas que
estão **roladas para a tela**. Quando isso acontece, um aviso vai para o log
dizendo quantas de quantas foram lidas. Devolver as 12 primeiras de 300 em
silêncio é como um bot reporta um número errado e ninguém percebe.

## Diálogos e hierarquia de janelas

O Windows tem **duas** relações verticais, e confundi-las é a armadilha clássica
do Win32:

- **pai** — contenção. Um botão *dentro* de um formulário. Não é algo que o
  usuário foca sozinho.
- **dono** — associação. Um diálogo que *pertence* a uma janela mas é top-level
  por conta própria. É o que um "Tem certeza?" modal realmente é.

`GetParent` mistura as duas: para uma janela top-level com dono, ele responde o
**dono**. A lib pergunta sempre a coisa sem ambiguidade.

```python
app.click(automation_id="btnExcluir")          # dispara o modal
modal = app.wait_dialog("Confirmar", timeout=10)
app.activate(hwnd=modal)
app.click(window_title="Confirmar", name="Sim")
```

| Método | Responde |
|---|---|
| `wait_dialog(titulo, timeout=)` | Bloqueia até a janela abrir um diálogo; devolve o handle |
| `dialog(titulo)` | O diálogo aberto agora, ou 0 — a forma de pergunta, para ramificar |
| `dialogs()` | Todos os diálogos que a janela possui |
| `owner_window()` | A aplicação por trás de um diálogo, ou 0 |
| `parent_window()` | A janela que contém esta, ou 0 se for top-level |
| `child_windows()` | Os HWND filhos, como `(hwnd, título, classe)` |
| `window_title()` · `window_class()` | Legenda e classe |
| `use_window(hwnd)` | Aponta a `App` para um handle específico |

`wait_dialog` **espera** de propósito: o modal não existe no instante em que o
clique acontece — a aplicação precisa construí-lo. Sem a espera, o bot responde
o diálogo *às vezes*, e no resto das vezes clica direto no que está atrás.

Ele devolve o handle em vez de repontar a `App` sozinho. Passe de volta como
`hwnd=` nos métodos de janela, e fica óbvio de qual janela cada linha fala.

`window_class()` costuma ser a única alça estável num diálogo: `#32770` é a
classe padrão de diálogo do Windows e não muda com o idioma da interface, ao
contrário da legenda.

## Erros dizem o que fazer

Tudo herda de `OneromDesktopError`, e as classes existem para separar os três
casos que pedem tratamento diferente:

```python
from onerom_desktop import (
    OneromDesktopError,          # base de tudo
    DesktopLocatorError,         # bug no bot — repetir não adianta
    DesktopBackendUnavailableError,  # ambiente quebrado — avisar o operador
    ElementNotFoundError,        # não achou agora — repetir pode funcionar
    WaitTimeoutError,
    MissingDependencyError,      # traz o comando de instalação na mensagem
)
```

`exists()` responde `False` para ausência de verdade, mas **levanta** exceção
quando a verificação em si não pôde ser feita — dependência faltando, backend
indisponível, imagem de template ausente, SAP fechado. Responder `False` nesses
casos é como um bot acaba entrando no branch errado.

## Dependências opcionais

O `import onerom_desktop` é barato: OpenCV, Tesseract, pywinauto e pywin32 só
são carregados quando a estratégia correspondente é usada.

```bash
pip install onerom-desktop              # coordenadas, teclado, screenshot
pip install "onerom-desktop[backend]"   # + UIAutomation (Windows)
pip install "onerom-desktop[image]"     # + OpenCV
pip install "onerom-desktop[ocr]"       # + Tesseract (o binário também é necessário)
pip install "onerom-desktop[sap]"       # + SAP GUI Scripting (Windows)
pip install "onerom-desktop[all]"       # tudo
```

Faltando uma delas, a mensagem já traz o `pip install` certo em vez de um
`ModuleNotFoundError` cru no log do bot.

## Coordenadas são pixels FÍSICOS

Toda coordenada nesta biblioteca é pixel físico da mesa virtual (a união de
todos os monitores) — o mesmo espaço em que o Inspector captura, o `mss`
fotografa e o `pyautogui` clica.

O processo é marcado como *per-monitor DPI aware* no import. Sem isso, o Windows
reescala silenciosamente as coordenadas de um processo DPI-unaware e todo clique
erra o alvo em tela a 125%/150% — o clássico "funciona na minha tela".

## Arquitetura

```
onerom_desktop/
├── app.py          App: defaults, dispatch e a cadeia auto
├── locators.py     kwargs soltos -> locators validados
├── strategies/     interface uniforme de ações (base + 5 estratégias)
├── engines/        uia · ocr · template · sap · windows
├── pointer.py      mouse           screen.py    captura de tela
├── keyboard.py     texto           geometry.py  Region
├── errors.py       hierarquia      optional.py  deps sob demanda
└── dpi.py          invariante de pixel físico
```

`strategies/base.py` concentra o caminho comum: as três estratégias que acabam
clicando um ponto (`ocr`, `image`, `coordinate`) só implementam `locate()` e
herdam todas as ações.

## Desenvolvimento

```bash
uv sync --group dev
./check.sh
```

MIT.
