diff --git a/CHANGELOG.md b/CHANGELOG.md index 65b0d8d..6a88260 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ Todas as mudanças relevantes do projeto. Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/); versões seguem [SemVer](https://semver.org/lang/pt-BR/). Próximos passos ficam no [roadmap](BACKLOG.md). +## [2.7.1] — 2026-10-18 + +### Corrigido +- **Botões que sumiam ao redimensionar:** na janela *Espaço por pasta* os botões da direita ("Abrir no + sistema", "Usar como Pasta Raiz", "Ocultar treemap") desapareciam abaixo de ~1220 px de largura, e na + janela principal "Apagar índice" ficava cortado no tamanho mínimo. A barra de botões da janela de espaço + agora tem **duas linhas** e o **tamanho mínimo das duas janelas é calculado pelo conteúdo** (fonte e + escala do sistema), nunca passando da tela. +- Barras de progresso paradas pareciam "meio cheias": agora só aparecem enquanto trabalham. + +### Alterado +- Janela principal abre mais alta (mais espaço para a lista de resultados); botão renomeado para + **Espaço e treemap...**; a métrica do treemap virou **Blocos por: Tamanho / Quantidade**. + +### Adicionado +- Teste automático de layout (`TestResponsiveLayout`): reduz e amplia as duas janelas e confere que nenhum + botão fica escondido, cortado ou fora da janela. +- `tools/screenshots.py`: gera as imagens do README a partir de um acervo **fictício** gravado direto no + índice (nada do computador de quem roda aparece). +- README reescrito com imagens (busca, treemap por tamanho e por quantidade, HTML exportado, tamanho + mínimo das janelas, CLI em texto e JSON). + ## [2.7.0] — 2026-10-17 ### Adicionado diff --git a/CITATION.cff b/CITATION.cff index 911621e..e897d31 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -11,8 +11,8 @@ abstract: >- authors: - family-names: Gambati given-names: Joberth Firmino -version: 2.7.0 -date-released: 2026-10-17 +version: 2.7.1 +date-released: 2026-10-18 license: MIT repository-code: "https://github.com/Yiuky/ReperIO" url: "https://github.com/Yiuky/ReperIO" diff --git a/README.md b/README.md index c090a9f..72ba14b 100644 --- a/README.md +++ b/README.md @@ -6,20 +6,21 @@

Encontre, indexe, exporte e copie arquivos em servidores de arquivos grandes e lentos, sem instalar nada
- Busca por nome e extensão · Índice local · Excel / CSV / TXT · Cópia paralela · SMB, DFS e unidades mapeadas · Windows e Linux + Busca por nome e extensão · Treemap interativo · Índice local · CLI com JSON para IAs · Excel / CSV / TXT · Cópia paralela · SMB, DFS e unidades mapeadas · Windows e Linux

Python 3.8+ Windows | Linux Sem dependências - Versão v2.7.0 + Versão v2.7.1 License MIT Testes

⬇️ Baixar • + 🖼️ Veja em ação • 📖 Como usar • 📋 Novidades • 🐞 Relatar problema • @@ -30,23 +31,102 @@ ---

- Janela principal do ReperIO + Treemap interativo do ReperIO por tamanho, com a tabela de espaço por pasta

## 📌 O que faz -O ReperIO varre pastas locais e **servidores de arquivos de rede** (SMB/CIFS, DFS, unidades mapeadas) -atrás de arquivos por **nome, extensão e tamanho**: centenas de milhares de pastas, pastas com dezenas de -milhares de arquivos e servidores que às vezes param de responder. Os achados formam o seu -**repertório**: você pode filtrar, inspecionar o **espaço ocupado por pasta**, ordenar, **exportar para -Excel, CSV ou TXT** e **copiar em lote**. +O ReperIO responde a três perguntas sobre pastas gigantes, locais ou em **servidor de arquivos de rede** +(SMB/CIFS, DFS, unidades mapeadas): -Foi criado para bancos de imagens e dados geográficos (GIS), mas serve a qualquer acervo grande de -arquivos em rede ou disco local. +1. **Onde está o arquivo?** Busca por nome, extensão e tamanho em centenas de milhares de pastas, + mesmo com servidores que às vezes param de responder. +2. **Onde foi parar o espaço?** Tamanho de cada pasta e subpasta, com **treemap interativo** (estilo + SpaceSniffer) por tamanho ou por quantidade de arquivos. +3. **Como automatizar?** Linha de comando com **saída JSON estável**, pronta para scripts, tarefas + agendadas e **IAs**. + +Os achados formam o seu **repertório**: filtre, ordene, **exporte para Excel, CSV ou TXT** e **copie +em lote**. Foi criado para bancos de imagens e dados geográficos (GIS), mas serve a qualquer acervo grande. > Projeto **pessoal e independente** de Joberth Firmino Gambati: não é um produto oficial de nenhuma > instituição nem fala em nome dela. +## 🖼️ Veja em ação + +Todas as imagens abaixo usam um acervo **fictício** (≈ 129 GB, 42 mil arquivos), gerado por +[`tools/screenshots.py`](tools/screenshots.py). + +### Busca e filtros + +

+ Janela principal do ReperIO com 105 arquivos .tif encontrados, 3 selecionados +

+ +Digite o padrão, filtre por extensão, tamanho e pastas ignoradas e clique em **Iniciar busca**. Os +resultados aparecem enquanto a busca acontece; o contador mostra o volume encontrado e o selecionado. + +### Espaço por pasta com treemap interativo + +

+ Treemap por tamanho: VIDEOS_DRONE ocupa metade do acervo +

+ +**Espaço e treemap...** abre um painel com o **treemap acima da tabela, na mesma janela**. A área de cada +bloco é proporcional ao **tamanho**: aqui, `VIDEOS_DRONE` ocupa metade do acervo. + +

+ Treemap por quantidade de arquivos: TILES_WEB domina +

+ +Em **Blocos por: Quantidade** o mesmo acervo muda de cara: `TILES_WEB` tem 42 mil arquivinhos e pesa +menos de 1 GB. É o tipo de pasta que deixa a busca, a cópia e o backup lentos sem ocupar espaço. + +| Ação | Resultado | +|---|---| +| **Passar o mouse** | Destaca o bloco e mostra caminho, tamanho, arquivos e participação | +| **Clicar** | Seleciona o bloco; a linha da tabela acompanha (e vice-versa) | +| **Duplo clique** ou **roda do mouse para cima** | Entra na pasta sob o mouse | +| **Roda para baixo**, **botão direito** ou **Backspace** | Sobe um nível | +| **Blocos por: Tamanho / Quantidade** | Troca a métrica | +| **Ocultar treemap** | Esconde o painel e deixa só a tabela | +| **Exportar... → Treemap (página HTML)** | Grava uma página autônoma, sem internet | + +A página HTML (também gerada por `--treemap mapa.html`) funciona em qualquer navegador, tem tema claro e +escuro e alterna entre tamanho e quantidade: + +

+ Treemap exportado em HTML, tema escuro, com a tabela dos maiores itens +

+ +### Fluido e responsivo + +A interface nunca trava (leitura de disco e rede ficam fora da thread da janela), o treemap se +redesenha ao redimensionar, e **nenhum botão some ao reduzir a janela**: o tamanho mínimo é calculado +a partir do que os controles realmente pedem (fonte e escala do sistema), e um teste automático +confere isso em vários tamanhos. + +

+ Janela principal no tamanho mínimo, com todos os botões visíveis +   + Janela de espaço por pasta no tamanho mínimo, com todos os botões visíveis +

+

As duas janelas no menor tamanho permitido: tudo continua visível e clicável.

+ +### Linha de comando e IAs + +

+ Saída em texto da CLI: espaço por pasta, por extensão e maiores arquivos +

+ +Com `--json`, a CLI entrega **um único objeto JSON** no stdout (mensagens vão para o stderr), com +campos estáveis, códigos de saída fixos e `--manifesto` para uma IA descobrir tudo sozinha: + +

+ Trecho simplificado da saída JSON de --espaco com subpastas +

+

Trecho simplificado: a saída real traz mais campos (veja docs/CLI.md).

+ ## 💡 Por que "ReperIO" Do latim ***reperire***, "achar, descobrir, trazer à luz", a mesma raiz de ***repertório*** @@ -59,10 +139,13 @@ faz: **acha** os arquivos e compõe o **repertório** do que foi encontrado. O * | | | |---|---| | 🔎 **Busca e filtros** | "Contém" (`IMAGENS_`) ou curingas (`*.shp`, `LC08_*_B4.TIF`), extensões, **tamanho** (`> 100 MB`, `10MB..500MB`) e **exclusão de pastas** (`_backup, node_modules`) | -| 📊 **Espaço por pasta** | Mostra quanto espaço cada pasta e subpasta ocupa (com `%`, quantidade de arquivos, navegação com duplo clique, pausa/retomada e exportação) | -| ⚡ **Rápido em pastas enormes** | Leitura em paralelo e em lotes grandes por ida ao servidor (~3× mais rápido em pastas com 65 mil arquivos) | -| 🗂️ **Índice local e compartilhável** | A 2ª busca na mesma pasta sai em segundos, com qualquer padrão e até com o servidor fora do ar; suporta indexação agendada via CLI (`--indexar`) | +| 🗺️ **Treemap interativo** | Blocos proporcionais ao **tamanho** ou à **quantidade de arquivos**, com dica ao passar o mouse, seleção sincronizada com a tabela e zoom pela roda do mouse; exporta para HTML | +| 📊 **Espaço por pasta** | Quanto cada pasta e subpasta ocupa (com `%`, quantidade de arquivos, navegação, pausa/retomada do mapeamento e exportação) | +| 🤖 **CLI para IAs e scripts** | `--json` estável, `--so-indice` (nunca varre o disco), `--manifesto`, códigos de saída fixos; ações `--espaco`, `--maiores`, `--extensoes`, `--buscar`, `--treemap`, `--status` | +| ⚡ **Rápido em pastas enormes** | Leitura em paralelo e em lotes grandes por ida ao servidor (~3× mais rápido em pastas com 65 mil arquivos); 869 mil arquivos indexados em ~85 s ([benchmark](docs/BENCHMARK.md)) | +| 🗂️ **Índice local e compartilhável** | A 2ª busca na mesma pasta sai em segundos, com qualquer padrão e até com o servidor fora do ar; indexação agendada via CLI (`--indexar`) | | 🛡️ **Não trava** | Uma pasta que o servidor não consegue abrir não segura a busca: o ReperIO troca de conexão e trata a pasta à parte | +| 📐 **Responsivo** | Nenhum botão some ao redimensionar; tamanho mínimo calculado pelo conteúdo e coberto por teste automático | | 📤 **Exportar** | Resultados e relatórios de espaço para **Excel (.xlsx)**, **CSV** ou **TXT**, sem precisar do Excel para gerar | | 📋 **Cópia paralela** | 4 cópias simultâneas, em blocos de 1 MB, com cancelamento a qualquer momento e nomes repetidos renomeados | | 💻 **Portátil** | Executável único para Windows e Linux; sem instalação, sem administrador, sem `pip` | @@ -75,6 +158,9 @@ faz: **acha** os arquivos e compõe o **repertório** do que foi encontrado. O * | **Linux x64** | `./ReperIO` | glibc 2.35+ (Ubuntu 22.04+, Debian 12+, Fedora 36+…) | | **macOS e outros** | [código-fonte](#-rodar-a-partir-do-código-fonte) | Python 3.8+ com tkinter | +A CLI (`--json`, `--espaco`, `--buscar`…) roda só com **Python 3.8+**, sem tkinter, inclusive em servidores +sem interface gráfica. + ## ⚡ Instalação 1. Baixe o `.zip` do seu sistema em **[Releases](https://github.com/Yiuky/ReperIO/releases/latest)**: @@ -106,8 +192,7 @@ Linux); caso contrário, abre o executável. Nos dois casos, nada é baixado nem novo troca o padrão sem perder o que já foi lido. 7. Na lista de resultados: - **Duplo clique** em um arquivo abre a pasta dele no sistema já destacando o arquivo; - - **Espaço por pasta...** abre o painel interativo com o consumo de espaço de cada subpasta (com - duplo clique para entrar nas subpastas, botão *Mapear pasta agora* com pausa/retomada e exportação); + - **Espaço e treemap...** abre o painel de [espaço por pasta com treemap](#espaço-por-pasta-com-treemap-interativo); - **Exportar...** salva a lista em Excel, CSV ou TXT; ou - informe o **Destino** e clique **Copiar selecionados**. *Plana* coloca tudo numa pasta; *Manter estrutura original* reproduz as subpastas. @@ -149,27 +234,30 @@ padrão ou filtro de tamanho. Na busca seguinte: - **Apagar índice** limpa tudo; desmarque **Usar** para ignorá-lo numa busca. - Fica em `%LOCALAPPDATA%\reperio\` (Windows) ou `~/.local/state/reperio/` (Linux), ou no caminho informado com `--indice ` (útil para compartilhar um índice na rede). -- **Modo sem janela (CLI)** para tarefas agendadas, auditoria rápida de disco e **uso por IAs**: - ```bash - python reperio.py --indexar "\\servidor\dados" --indice "\\servidor\dados\_reperio.sqlite" - python reperio.py --espaco "C:\Users\voce" --top 25 --exportar espaco.xlsx - python reperio.py --maiores 20 --em D:\ --so-indice # 20 maiores arquivos, sem tocar o disco - python reperio.py --buscar "*.iso" --em D:\ --tamanho "> 1 GB" --json - python reperio.py --espaco D:\ --treemap mapa.html # mapa visual em HTML - ``` - `--json` entrega **um objeto JSON** estável no stdout, `--so-indice` garante respostas instantâneas, - `--manifesto` descreve a CLI em JSON e os códigos de saída são fixos. Referência em - [docs/CLI.md](docs/CLI.md); guia para agentes em [docs/IA.md](docs/IA.md); números medidos em - [docs/BENCHMARK.md](docs/BENCHMARK.md). - -### 🗺️ Treemap interativo - -Em **Espaço por pasta...**, um painel de **treemap fica acima da tabela, na mesma janela** (estilo -SpaceSniffer): a **área dos blocos é proporcional ao tamanho ou à quantidade de arquivos** (botão -**Por: tamanho / quantidade**; **Ocultar treemap** esconde o painel). Passe o mouse para ver os detalhes, -clique para selecionar (a tabela acompanha), **roda do mouse** ou duplo clique para entrar numa pasta, -botão direito para subir. **Exportar... → Treemap -(página HTML)** ou `--treemap mapa.html` gera uma página autônoma (sem internet, tema claro/escuro). + +### Modo sem janela (CLI), para scripts e IAs + +```bash +python reperio.py --indexar "\\servidor\dados" --indice "\\servidor\dados\_reperio.sqlite" +python reperio.py --espaco "C:\Users\voce" --top 25 --exportar espaco.xlsx +python reperio.py --maiores 20 --em D:\ --so-indice # 20 maiores arquivos, sem tocar o disco +python reperio.py --extensoes 10 --em D:\ --so-indice # espaço por extensão +python reperio.py --buscar "*.iso" --em D:\ --tamanho "> 1 GB" --json +python reperio.py --espaco D:\ --treemap mapa.html # mapa visual em HTML +python reperio.py --manifesto # a CLI descrevendo a si mesma, em JSON +``` + +| Ideia | Como | +|---|---| +| Saída para máquinas | `--json`: um objeto JSON em ASCII no stdout; progresso e avisos no stderr | +| Nunca varrer sem querer | `--so-indice`: sem dados no índice, termina com código **3** | +| Descobrir sozinha | `--manifesto`: argumentos, campos de saída, códigos e exemplos em JSON | +| Códigos de saída | `0` ok · `1` erro · `2` uso incorreto · `3` pasta/índice ausente · `130` Ctrl+C | +| Erro de digitação | Argumento desconhecido é erro de uso: a janela **não** abre por engano | + +O ReperIO **só lê**: nenhuma ação da CLI apaga, move ou altera arquivos. Referência completa em +[docs/CLI.md](docs/CLI.md); guia e receitas para agentes em [docs/IA.md](docs/IA.md); números medidos +em discos reais em [docs/BENCHMARK.md](docs/BENCHMARK.md). ## 🛡️ Confiável em redes lentas e instáveis @@ -314,7 +402,7 @@ python -m unittest discover -s tests -v # Windows xvfb-run -a python3 -m unittest discover -s tests -v # Linux sem tela ``` -95 testes, incluindo a simulação dos cenários vistos em servidor real: pasta que trava a conexão, +96 testes, incluindo a simulação dos cenários vistos em servidor real: pasta que trava a conexão, vítimas refeitas na hora, cascata entre conexões, pastas grandes que não são travamento, nomes inválidos de servidores Samba antigos, filtros de tamanho/exclusão, análise de espaço por pasta, treemap, CLI/JSON e exportação com datas corrompidas. O GitHub Actions roda a suíte em Windows e Linux, com Python 3.8 e @@ -346,8 +434,11 @@ do [CITATION.cff](CITATION.cff)) ou assim: **ReperIO** (from Latin *reperire*, "to find, to discover", the root of *repertory*) is a portable, dependency-free desktop tool for **Windows and Linux** that searches local folders and **network file servers** (SMB/CIFS, DFS, mapped drives) for files by name, extension and size, analyzes **disk/folder -space usage**, builds a local **SQLite index** for instant repeated searches, **exports** results to -Excel (.xlsx), CSV or TXT, and **copies** files in parallel. +space usage** with an interactive **treemap** (by size or by file count), builds a local **SQLite index** +for instant repeated searches, **exports** results to Excel (.xlsx), CSV or TXT, and **copies** files in +parallel. A headless **CLI with stable JSON output** (`--json`, `--so-indice`, `--manifesto`, fixed exit +codes) makes it ready for scripts, scheduled tasks and AI agents; the UI never hides a button when the +window is resized. It was built for very large, slow and unreliable file servers. On Windows, a single folder that the server cannot open stalls the whole SMB connection for minutes; ReperIO opens **several independent diff --git a/docs/CLI.md b/docs/CLI.md index 41d4ff3..5b425f4 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -117,7 +117,7 @@ de primeiro nível. Serve para achar em segundos "o que está comendo o disco". - **Na janela (painel interativo, estilo SpaceSniffer):** em **Espaço por pasta...** o treemap fica em um painel **acima da tabela**, na mesma janela (a divisória é ajustável; **Ocultar treemap** esconde o - painel e **Por: tamanho / quantidade** troca a métrica). Mostra 3 níveis de subpastas aninhadas. + painel e **Blocos por: Tamanho / Quantidade** troca a métrica). Mostra 3 níveis de subpastas aninhadas. - **Passar o mouse:** destaca o bloco e mostra uma dica com caminho, tamanho, arquivos e participação. - **Clique:** seleciona o bloco e marca a linha correspondente da tabela (e vice-versa: escolher uma linha da tabela contorna o bloco). diff --git a/docs/images/cli_json.png b/docs/images/cli_json.png new file mode 100644 index 0000000..35402be Binary files /dev/null and b/docs/images/cli_json.png differ diff --git a/docs/images/cli_texto.png b/docs/images/cli_texto.png new file mode 100644 index 0000000..ecd557f Binary files /dev/null and b/docs/images/cli_texto.png differ diff --git a/docs/images/espaco_tamanho_minimo.png b/docs/images/espaco_tamanho_minimo.png new file mode 100644 index 0000000..aa2cbbc Binary files /dev/null and b/docs/images/espaco_tamanho_minimo.png differ diff --git a/docs/images/espaco_treemap_quantidade.png b/docs/images/espaco_treemap_quantidade.png new file mode 100644 index 0000000..a42db97 Binary files /dev/null and b/docs/images/espaco_treemap_quantidade.png differ diff --git a/docs/images/espaco_treemap_tamanho.png b/docs/images/espaco_treemap_tamanho.png new file mode 100644 index 0000000..cb3b761 Binary files /dev/null and b/docs/images/espaco_treemap_tamanho.png differ diff --git a/docs/images/janela_principal.png b/docs/images/janela_principal.png index 90da2a7..632aae6 100644 Binary files a/docs/images/janela_principal.png and b/docs/images/janela_principal.png differ diff --git a/docs/images/janela_principal_minima.png b/docs/images/janela_principal_minima.png new file mode 100644 index 0000000..5cedbe8 Binary files /dev/null and b/docs/images/janela_principal_minima.png differ diff --git a/docs/images/treemap_html.png b/docs/images/treemap_html.png new file mode 100644 index 0000000..55681d0 Binary files /dev/null and b/docs/images/treemap_html.png differ diff --git a/reperio.py b/reperio.py index 6468fd3..70382b5 100644 --- a/reperio.py +++ b/reperio.py @@ -36,7 +36,7 @@ import time import traceback -__version__ = "2.7.0" +__version__ = "2.7.1" APP_NAME = "ReperIO" APP_TAGLINE = "busca, índice, exportação e cópia de arquivos em servidores" APP_URL = "https://github.com/Yiuky/ReperIO" @@ -3108,6 +3108,18 @@ def _build(self): self._filter(tp) self._results(mp) self._copy_panel(bp) + # Tamanho mínimo = o que os painéis pedem + um pedaço útil da lista de resultados + self.root.update_idletasks() + need_w = max(tp.winfo_reqwidth(), mp.winfo_reqwidth(), bp.winfo_reqwidth()) + need_h = (tp.winfo_reqheight() + bp.winfo_reqheight() + 150 + + sum(w.winfo_reqheight() for w in self.root.winfo_children() + if w.winfo_manager() == "pack" and w is not pane)) + sw, sh = self.root.winfo_screenwidth(), self.root.winfo_screenheight() + self.root.minsize(min(max(900, need_w), sw - 40), min(max(560, need_h), sh - 90)) + cw, ch = self.root.winfo_width(), self.root.winfo_height() + mw, mh = self.root.minsize() + if cw < mw or ch < mh: + self.root.geometry("%dx%d" % (max(cw, mw), max(ch, mh))) def _hdr(self): h = ttk.Frame(self.root, style="Panel.TFrame") @@ -3828,8 +3840,8 @@ def __init__(self, app, initial_dir=""): self.app = app self.win = tk.Toplevel(app.root) self.win.title("Espaço por pasta — %s" % APP_NAME) - self.win.geometry("1260x760") - self.win.minsize(900, 480) + self.win.geometry("1200x780") + self.win.minsize(720, 480) self.win.configure(bg=app.BG) self._closed = False self._q = queue.Queue() @@ -3851,6 +3863,11 @@ def __init__(self, app, initial_dir=""): self._tm_after = None self._tm_last_zoom = 0.0 self._build(initial_dir) + self.win.update_idletasks() + fixed = [w for w in self.win.winfo_children() if w.winfo_manager() == "pack" and w is not self.pw] + fit_minsize(self.win, 720, 480, + need_w=max(w.winfo_reqwidth() for w in fixed) + 8, + need_h=sum(w.winfo_reqheight() for w in fixed) + 260) # + treemap e tabela self.win.protocol("WM_DELETE_WINDOW", self._close) self._poll() if initial_dir: @@ -3893,19 +3910,23 @@ def _build(self, initial_dir): self.b_export = ttk.Button(bf, text="Exportar...", style="Primary.TButton", command=self._export) self.b_export.pack(side="right", padx=(4, 0)) - self.f_metric = ttk.Frame(bf, style="Panel.TFrame") - self.f_metric.pack(side="right", padx=(4, 8)) + + # 2ª linha: visualização (treemap) à esquerda, ações sobre a pasta à direita + bf2 = ttk.Frame(top, style="Panel.TFrame") + bf2.grid(row=2, column=0, columnspan=3, padx=12, pady=(0, 8), sticky="ew") + self.b_tm = ttk.Button(bf2, text="Ocultar treemap", style="Secondary.TButton", + command=self._toggle_treemap) + self.b_tm.pack(side="left", padx=(0, 4)) + self.f_metric = ttk.Frame(bf2, style="Panel.TFrame") + self.f_metric.pack(side="left", padx=(8, 4)) ttk.Label(self.f_metric, text="Blocos por:", style="Panel.TLabel").pack(side="left", padx=(0, 4)) self.v_metric = tk.StringVar(value="tamanho") for txt, val in (("Tamanho", "tamanho"), ("Quantidade", "quantidade")): ttk.Radiobutton(self.f_metric, text=txt, value=val, variable=self.v_metric, style="Dark.TRadiobutton", command=self._set_metric).pack(side="left", padx=2) - self.b_tm = ttk.Button(bf, text="Ocultar treemap", style="Secondary.TButton", - command=self._toggle_treemap) - self.b_tm.pack(side="right", padx=4) - ttk.Button(bf, text="Abrir no sistema", style="Secondary.TButton", - command=self._open_selected).pack(side="right", padx=4) - ttk.Button(bf, text="Usar como Pasta Raiz", style="Secondary.TButton", + ttk.Button(bf2, text="Abrir no sistema", style="Secondary.TButton", + command=self._open_selected).pack(side="right", padx=(4, 0)) + ttk.Button(bf2, text="Usar como Pasta Raiz", style="Secondary.TButton", command=self._use_as_root).pack(side="right", padx=4) ttk.Separator(self.win, orient="horizontal").pack(fill="x") @@ -3916,7 +3937,7 @@ def _build(self, initial_dir): sf, text=" Informe uma pasta para ver o espaço ocupado por cada subpasta.", style="Counter.TLabel") self.lbl_sum.pack(side="left", padx=8, pady=5) - ttk.Label(sf, text="Cor = pasta de 1º nível · cinza = arquivos soltos · roda do mouse entra/sobe · botão direito sobe ", + ttk.Label(sf, text="Roda do mouse: entra/sobe · botão direito: sobe ", style="Sub.TLabel").pack(side="right", padx=8, pady=5) self.pw = ttk.PanedWindow(self.win, orient="vertical") @@ -4100,7 +4121,7 @@ def _toggle_treemap(self): if self._tm_on: self.pw.insert(0, self._tm_frame, weight=3) self.b_tm.config(text="Ocultar treemap") - self.f_metric.pack(side="right", padx=(4, 8)) + self.f_metric.pack(side="left", padx=(8, 4)) self._tm_schedule() else: self.pw.forget(self._tm_frame) @@ -4478,6 +4499,24 @@ def _close(self): pass # =========================================================================== +def fit_minsize(win, min_w, min_h, need_w=None, need_h=None): + """ + Define o tamanho mínimo da janela a partir do que os controles realmente pedem + (fontes e DPI mudam de máquina para máquina), para que nenhum botão fique + cortado ou escondido ao reduzir a janela. Nunca passa da tela. Devolve (w, h). + `need_w`/`need_h`: o que os controles fixos pedem (padrão: o pedido da janela toda). + """ + win.update_idletasks() + w = max(min_w, win.winfo_reqwidth() if need_w is None else need_w) + h = max(min_h, win.winfo_reqheight() if need_h is None else need_h) + w = min(w, max(min_w, win.winfo_screenwidth() - 40)) + h = min(h, max(min_h, win.winfo_screenheight() - 90)) + win.minsize(w, h) + cw, ch = win.winfo_width(), win.winfo_height() + if cw < w or ch < h: # janela já aberta menor que o mínimo: cresce + win.geometry("%dx%d" % (max(cw, w), max(ch, h))) + return w, h + def _hide_own_console(): """Esconde a janela de console quando ela foi criada SÓ para este processo (duplo clique no .py com python.exe). Se o script foi chamado de um diff --git a/tests/test_cli_treemap.py b/tests/test_cli_treemap.py index 658b8bc..0c8b51a 100644 --- a/tests/test_cli_treemap.py +++ b/tests/test_cli_treemap.py @@ -355,5 +355,82 @@ class Ev: shutil.rmtree(base, ignore_errors=True) +def _all_widgets(w): + yield w + for c in w.winfo_children(): + yield from _all_widgets(c) + + +def hidden_buttons(top): + """Botões/opções que, no tamanho atual, estão cortados, fora da janela ou escondidos.""" + top.update_idletasks() + top.update() + W, H = top.winfo_width(), top.winfo_height() + bad = [] + for w in _all_widgets(top): + if w.winfo_class() not in ("TButton", "TCheckbutton", "TRadiobutton"): + continue + name = str(w.cget("text")) + if not w.winfo_ismapped(): + if w.winfo_manager(): + bad.append((name, "não mapeado")) + continue + right = w.winfo_rootx() + w.winfo_width() + bottom = w.winfo_rooty() + w.winfo_height() + if (right > top.winfo_rootx() + W + 1 or bottom > top.winfo_rooty() + H + 1 + or w.winfo_width() < w.winfo_reqwidth() - 2): + bad.append((name, "cortado/fora da janela")) + continue + anc = w.master + while anc is not None and anc is not top: # cortado por um contêiner (painel) + if (right > anc.winfo_rootx() + anc.winfo_width() + 1 + or bottom > anc.winfo_rooty() + anc.winfo_height() + 1): + bad.append((name, "cortado pelo contêiner " + anc.winfo_class())) + break + anc = anc.master + return bad + + +class TestResponsiveLayout(unittest.TestCase): + """Nenhum botão pode sumir ao redimensionar: o tamanho mínimo vem do conteúdo.""" + + def test_buttons_stay_visible_at_any_size(self): + if g.tk is None: + self.skipTest("tkinter indisponível") + try: + root = g.tk.Tk() + except g.tk.TclError as e: + self.skipTest("sem display: %s" % e) + base = tempfile.mkdtemp(prefix="rp_resp_") + idx = g.Index(os.path.join(base, "idx.sqlite")) + try: + app = g.App(root, idx) + root.update() + mw, mh = root.minsize() + self.assertGreaterEqual(mw, 900) + for w, h in ((mw, mh), (mw + 120, mh + 60), (1100, 820), (1600, 900), (mw, mh + 200)): + root.geometry("%dx%d+0+0" % (w, h)) + self.assertEqual(hidden_buttons(root), [], "janela principal %dx%d" % (w, h)) + # pedir menos que o mínimo é barrado pelo minsize: continua tudo visível + root.geometry("500x300+0+0") + self.assertEqual(hidden_buttons(root), [], "janela principal abaixo do mínimo") + app._open_space() + dlg = app._space_win.win + dlg.update() + dw, dh = dlg.minsize() + for w, h in ((dw, dh), (dw + 80, dh + 40), (1200, 780), (1600, 900), (dw, 900)): + dlg.geometry("%dx%d+0+0" % (w, h)) + self.assertEqual(hidden_buttons(dlg), [], "janela de espaço %dx%d" % (w, h)) + dlg.geometry("300x200+0+0") + self.assertEqual(hidden_buttons(dlg), [], "janela de espaço abaixo do mínimo") + app._space_win._close() + finally: + idx.close() + root.destroy() + app = root = None + gc.collect() + shutil.rmtree(base, ignore_errors=True) + + if __name__ == "__main__": unittest.main() diff --git a/tools/screenshots.py b/tools/screenshots.py new file mode 100644 index 0000000..3a7b7be --- /dev/null +++ b/tools/screenshots.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Gera as imagens do README (docs/images/) a partir de um acervo FICTÍCIO: nada do computador de quem +roda aparece nas capturas. O acervo é gravado direto no índice SQLite (sem criar arquivos em disco), +por isso a pasta "D:\\Acervo_Geoespacial" não precisa existir. + +Só Windows. Requer Pillow (apenas para este script: `pip install pillow`) e o Microsoft Edge (para a +captura do treemap em HTML). Uso: python tools/screenshots.py +""" +import contextlib +import io +import json +import os +import random +import shutil +import subprocess +import sys +import tempfile +import time + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, ROOT) +import reperio as g # noqa: E402 +from PIL import Image, ImageDraw, ImageFont, ImageGrab # noqa: E402 + +OUT = os.path.join(ROOT, "docs", "images") +DEMO_ROOT = "D:\\Acervo_Geoespacial" +NOW = time.mktime((2026, 10, 17, 9, 30, 0, 0, 0, -1)) +MB, GB = 1 << 20, 1 << 30 + + +def build_demo(index): + """Grava um acervo geoespacial fictício (≈ 120 GB, ≈ 45 mil arquivos) no índice.""" + rnd = random.Random(7) + tree = {} # pasta -> (subpastas, arquivos) + + def put(path, subdirs=(), files=()): + tree[path] = (list(subdirs), list(files)) + + def mt(): + return NOW - rnd.randint(1, 900) * 86400 + + cenas = ("226_068", "227_069", "228_070") + anos = (2019, 2020, 2021, 2022, 2023) + p_land = DEMO_ROOT + "\\LANDSAT" + put(p_land, [str(a) for a in anos]) + for a in anos: + pa = "%s\\%d" % (p_land, a) + put(pa, cenas) + for c in cenas: + base = "LC08_L2SP_%s_%d0715" % (c.replace("_", ""), a) + files = [("%s_%s.TIF" % (base, b), int(rnd.uniform(60, 130) * MB), mt()) + for b in ("B2", "B3", "B4", "B5", "B6", "B7")] + files.append((base + "_MTL.xml", 9 * 1024, mt())) + put("%s\\%s" % (pa, c), (), files) + p_s2 = DEMO_ROOT + "\\SENTINEL" + muns = ("Cuiaba", "Sinop", "Rondonopolis", "Barra_do_Garcas", "Caceres", "Lucas_do_Rio_Verde") + put(p_s2, ["2023"]) + put(p_s2 + "\\2023", muns) + for m in muns: + put("%s\\2023\\%s" % (p_s2, m), (), [ + ("S2A_MSIL2A_20230812_%s_NDVI.tif" % m, int(rnd.uniform(380, 520) * MB), mt()), + ("S2A_MSIL2A_20230812_%s_RGB.tif" % m, int(rnd.uniform(600, 800) * MB), mt()), + ("S2A_MSIL2A_20230812_%s_meta.json" % m, 4096, mt())]) + p_mos = DEMO_ROOT + "\\MOSAICOS" + put(p_mos, (), [("IMAGENS_mosaico_2020.tif", int(6.2 * GB), mt()), + ("IMAGENS_mosaico_2021.tif", int(7.1 * GB), mt()), + ("IMAGENS_mosaico_2022.ecw", int(1.1 * GB), mt()), + ("IMAGENS_mosaico_2023.tif", int(7.8 * GB), mt())]) + p_vet = DEMO_ROOT + "\\VETORES" + put(p_vet, ["limites", "car"]) + put(p_vet + "\\limites", (), [("%s%s" % (n, e), int(rnd.uniform(5, 800) * 1024), mt()) + for n in ("Cuiaba", "Sinop", "Rondonopolis", "Caceres") + for e in (".shp", ".shx", ".dbf", ".prj")]) + put(p_vet + "\\car", (), [("CAR_MT_2023.gpkg", int(1.4 * GB), mt()), + ("CAR_MT_2022.gpkg", int(1.2 * GB), mt())]) + p_vid = DEMO_ROOT + "\\VIDEOS_DRONE" + put(p_vid, ["2022", "2023"]) + for ano in ("2022", "2023"): + put("%s\\%s" % (p_vid, ano), (), [("DJI_%04d.MP4" % i, int(rnd.uniform(0.9, 3.2) * GB), mt()) + for i in range(1, 15)]) + p_bk = DEMO_ROOT + "\\BACKUP" + put(p_bk, (), [("acervo_2022_completo.zip", int(9.4 * GB), mt()), + ("acervo_2023_completo.zip", int(12.6 * GB), mt()), + ("vetores_2024-01.zip", int(0.6 * GB), mt())]) + p_doc = DEMO_ROOT + "\\DOCUMENTOS" + put(p_doc, (), [("Relatorio_IMAGENS_%d_%02d.pdf" % (2020 + i // 12, i % 12 + 1), + int(rnd.uniform(0.2, 4.5) * MB), mt()) for i in range(60)]) + p_til = DEMO_ROOT + "\\TILES_WEB" + put(p_til, ["z14", "z15"]) + for z, n in (("z14", 9000), ("z15", 33000)): + put("%s\\%s" % (p_til, z), (), [("tile_%06d.png" % i, int(rnd.uniform(8, 38) * 1024), mt()) + for i in range(n)]) + put(DEMO_ROOT, ["LANDSAT", "SENTINEL", "MOSAICOS", "VETORES", "VIDEOS_DRONE", "BACKUP", + "DOCUMENTOS", "TILES_WEB"], [("LEIAME.txt", 2048, NOW)]) + for path, (subs, files) in tree.items(): + index.record_dir(path, subs, files, dmtime=NOW) + index.flush() + + +def pump(root, sec): + end = time.time() + sec + while time.time() < end: + root.update() + time.sleep(0.02) + + +def grab(win, name): + win.update() + x, y = win.winfo_rootx(), win.winfo_rooty() + ImageGrab.grab(bbox=(x, y, x + win.winfo_width(), y + win.winfo_height())).save( + os.path.join(OUT, name)) + print(" ->", name) + + +def block(dlg, name, depth=2, nth=0): + cand = [r for r in dlg._tm_rects if r["node"]["name"] == name and r["depth"] >= depth - 1] + return cand[nth] if cand else None + + +class Ev(object): + def __init__(self, r): + self.x, self.y = int(r["x"] + r["w"] / 2), int(r["y"] + r["h"] / 2) + + +def gui_shots(index_path): + index = g.Index(index_path) + root = g.tk.Tk() + root.geometry("1100x820+10+10") + app = g.App(root, index) + pump(root, 1.0) + root.lift() + root.attributes("-topmost", True) + app.v_root.set(DEMO_ROOT) + app.v_pat.set("*.tif") + app._start() + deadline = time.time() + 20 + while (app._running or app._rcount == 0) and time.time() < deadline: + pump(root, 0.1) + pump(root, 0.8) + app.tree.selection_set(app.tree.get_children()[:3]) + pump(root, 0.3) + print("janela principal") + grab(root, "janela_principal.png") + + app._open_space() + dlg = app._space_win + dlg.win.geometry("1200x780+30+20") + dlg.win.attributes("-topmost", True) + pump(root, 2.5) + print("treemap por tamanho") + mosaicos = block(dlg, "MOSAICOS", 1) + dlg._tm_click(Ev(mosaicos)) + other = block(dlg, "acervo_2023_completo.zip") or block(dlg, "BACKUP", 1) + dlg._tm_motion(Ev(other or mosaicos)) + pump(root, 0.5) + grab(dlg.win, "espaco_treemap_tamanho.png") + + print("treemap por quantidade") + dlg.v_metric.set("quantidade") + dlg._set_metric() + pump(root, 0.8) + tiles = block(dlg, "TILES_WEB", 1) + dlg._tm_click(Ev(tiles)) + dlg._tm_motion(Ev(tiles)) + pump(root, 0.5) + grab(dlg.win, "espaco_treemap_quantidade.png") + + print("tamanho mínimo da janela de espaço") + dlg.v_metric.set("tamanho") + dlg._set_metric() + mw, mh = dlg.win.minsize() + dlg.win.geometry("%dx%d+30+20" % (mw, mh)) + pump(root, 1.0) + grab(dlg.win, "espaco_tamanho_minimo.png") + dlg._close() + mw, mh = root.minsize() + root.geometry("%dx%d+10+10" % (mw, mh)) + pump(root, 1.0) + print("tamanho mínimo da janela principal") + grab(root, "janela_principal_minima.png") + index.close() + root.destroy() + + +def cli(*args): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + try: + g.main(list(args)) + except SystemExit: + pass + return out.getvalue() + + +def terminal_png(title, blocks, dest, width=1040): + """Desenha uma 'janela de terminal' com os comandos e suas saídas (fonte monoespaçada).""" + font = None + for f in ("consola.ttf", "Consolas.ttf", "cour.ttf"): + try: + font = ImageFont.truetype(f, 15) + break + except OSError: + continue + font = font or ImageFont.load_default() + lines = [] + for cmd, out in blocks: + lines.append(("$ " + cmd, "#7ee787")) + for ln in out.rstrip("\n").split("\n"): + lines.append((ln, "#d6dde4")) + lines.append(("", "#d6dde4")) + lh = 20 + img = Image.new("RGB", (width, 36 + lh * len(lines) + 16), "#0d1117") + d = ImageDraw.Draw(img) + d.rectangle((0, 0, width, 34), fill="#1f2630") + for i, c in enumerate(("#ff5f56", "#ffbd2e", "#27c93f")): + d.ellipse((14 + i * 22, 11, 26 + i * 22, 23), fill=c) + d.text((100, 9), title, fill="#8b949e", font=font) + y = 46 + for text, color in lines: + d.text((16, y), text, fill=color, font=font) + y += lh + img.save(dest) + print(" ->", os.path.basename(dest)) + + +def cli_shots(index_path): + base = ["--indice", index_path, "--so-indice"] + top = cli(*base, "--espaco", DEMO_ROOT, "--top", "6") + ext = cli(*base, "--extensoes", "5", "--em", DEMO_ROOT) + big = cli(*base, "--maiores", "5", "--em", DEMO_ROOT) + terminal_png("ReperIO — espaço, extensões e maiores arquivos", [ + ("reperio --espaco D:\\Acervo_Geoespacial --top 6", top), + ("reperio --extensoes 5 --em D:\\Acervo_Geoespacial", ext), + ("reperio --maiores 5 --em D:\\Acervo_Geoespacial", big)], + os.path.join(OUT, "cli_texto.png"), width=1180) + raw = cli(*base, "--espaco", DEMO_ROOT, "--json", "--top", "2", "--profundidade", "2") + data = json.loads(raw) + for it in data["espaco"]["itens"]: + for ch in it.get("filhos", []): + ch.pop("filhos", None) + data.pop("varredura", None) + data["espaco"]["itens"] = data["espaco"]["itens"][:2] + for it in data["espaco"]["itens"]: + it["filhos"] = it.get("filhos", [])[:2] + for k in ("bytes_diretos", "arquivos_diretos", "subpastas", "pct_pai"): + it.pop(k, None) + for ch in it["filhos"]: + ch.pop(k, None) + data.pop("desempenho", None) + pretty = json.dumps(data, ensure_ascii=False, indent=2) + terminal_png("ReperIO --json — saída para scripts e IAs", [ + ("reperio --espaco D:\\Acervo_Geoespacial --json --top 2 --profundidade 2 --so-indice", + pretty)], os.path.join(OUT, "cli_json.png"), width=1040) + + +def html_shot(index_path, tmp): + html = os.path.join(tmp, "treemap.html") + cli("--indice", index_path, "--so-indice", "--espaco", DEMO_ROOT, "--treemap", html, "-q") + edge = shutil.which("msedge") or os.path.join( + os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), + "Microsoft", "Edge", "Application", "msedge.exe") + if not os.path.exists(edge): + print(" (Edge não encontrado: pulei treemap_html.png)") + return + shot = os.path.join(OUT, "treemap_html.png") + subprocess.run([edge, "--headless=new", "--disable-gpu", "--hide-scrollbars", + "--window-size=1280,1180", "--screenshot=" + shot, + "file:///" + html.replace("\\", "/")], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=90) + time.sleep(1) + print(" -> treemap_html.png") + + +def main(): + os.makedirs(OUT, exist_ok=True) + tmp = tempfile.mkdtemp(prefix="reperio_shots_") + index_path = os.path.join(tmp, "demo.sqlite") + try: + idx = g.Index(index_path) + build_demo(idx) + idx.close() + print("acervo fictício gravado") + gui_shots(index_path) + cli_shots(index_path) + html_shot(index_path, tmp) + finally: + shutil.rmtree(tmp, ignore_errors=True) + print("imagens em", OUT) + + +if __name__ == "__main__": + main()