diff --git a/src/ui/AGENTS.md b/src/ui/AGENTS.md new file mode 100644 index 0000000..6c2e1a8 --- /dev/null +++ b/src/ui/AGENTS.md @@ -0,0 +1,286 @@ +# AGENTS.md — Guia de desenvolvimento da UI + +Este documento descreve como a interface do **Comparador de Notas** é estruturada, +quais padrões devem ser seguidos e o que não fazer. Leia antes de criar ou modificar +qualquer arquivo em `src/ui/`. + +--- + +## 1. Arquitetura da UI + +O projeto usa **iced 0.13** com arquitetura Elm (Model / Update / View). + +``` +src/ui/ +├── app.rs Estado global (App), update, view raiz, breadcrumb +├── message.rs Enum Message — todos os eventos da UI +├── mod.rs Re-exporta submódulos +├── theme.rs Paleta de cores, estilos de widgets (sem lógica) +├── components/ +│ ├── modal.rs Overlay de modal bloqueante +│ ├── paginacao.rs Controles de paginação (◀ / ▶) +│ └── tabela_preview.rs Tabela de pré-visualização do arquivo importado +└── screens/ + ├── import.rs Tela 1 — seleção de arquivo e layout + ├── selecionar_aba.rs Tela 1.5 — seleção de aba XLSX + ├── configuracao_colunas.rs Tela 2 — mapeamento de colunas + ├── resultado.rs Tela 3 — resultado da análise + └── layouts.rs Tela lateral — gerenciamento de layouts +``` + +### Responsabilidades por camada + +| Camada | Responsabilidade | +|--------|-----------------| +| `app.rs` | Estado global, `update()`, `view()` raiz, roteamento entre telas, tarefas assíncronas | +| `screens/` | Renderização de cada tela: coleta inputs, monta widgets, emite `Message` | +| `components/` | Widgets reutilizáveis sem estado próprio (recebem dados por parâmetro) | +| `theme.rs` | Apenas estilos visuais. Sem lógica de negócio. Sem `Message`. | +| `message.rs` | Todos os eventos possíveis da UI. Nenhuma lógica aqui. | + +**Regra:** nenhuma lógica de domínio (parsing, validação de sequência, cálculos) pode +estar em `src/ui/`. A UI apenas chama use cases de `src/application/`. + +--- + +## 2. Tema e paleta de cores + +Todas as cores estão em `theme.rs` como constantes `Color`. **Nunca use valores RGB +literais fora de `theme.rs`.** + +### Paleta + +| Constante | Hex | Uso | +|-----------|-----|-----| +| `BG` | `#0F172A` | Fundo geral da janela | +| `SURFACE` | `#1E293B` | Cards primários | +| `SURFACE_2` | `#334155` | Cards secundários, cabeçalho de tabela | +| `BORDER` | `#334155` | Bordas de inputs e cards | +| `TEXT` | `#F1F5F9` | Texto principal | +| `TEXT_SECONDARY` | `#94A3B8` | Labels, placeholders | +| `TEXT_MUTED` | `#64748B` | Texto desabilitado | +| `PRIMARY` | `#3B82F6` | Botões primários, step ativo | +| `PRIMARY_HOVER` | `#2563EB` | Hover em botões primários | +| `SUCCESS` | `#22C55E` | Badge OK, barra ≥ 90% | +| `WARNING` | `#F59E0B` | Badge faltante, barra 60–89% | +| `DANGER` | `#EF4444` | Badge duplicada, barra < 60% | +| `TRACK_BG` | `#111827` | Trilha da progress bar | + +### Helpers de estilo disponíveis em `theme.rs` + +**Containers:** +- `t::fundo` — fundo geral da janela +- `t::card` — card principal (SURFACE + borda + radius 8) +- `t::card_secondary` — card secundário (SURFACE_2 + borda + radius 6) +- `t::cabecalho_tabela` — cabeçalho de tabela sem borda +- `t::badge_sucesso` / `t::badge_aviso` / `t::badge_perigo` — badges coloridos +- `t::area_erro` — área de validação com fundo vermelho sutil +- `t::stat_card` — card de estatística (igual a `t::card`) +- `t::separador` — linha divisória fina +- `t::breadcrumb_bg` — fundo da barra de breadcrumb + +**Botões:** +- `t::btn_primary` — azul sólido, ação principal +- `t::btn_secondary` — SURFACE_2, ação secundária +- `t::btn_ghost` — transparente com borda, ação terciária +- `t::btn_danger` — vermelho semitransparente, exclusão +- `t::btn_aba_ativa` / `t::btn_aba_inativa` — seleção de aba XLSX +- `t::btn_pagina_ativo` / `t::btn_pagina_inativo` — paginação + +**Inputs:** +- `t::input_dark` — text_input com fundo BG, borda BORDER, focus PRIMARY + +**Progress bar:** +- `t::progress_bar_por_percentual(f32)` — retorna closure com cor por threshold: + - ≥ 0.90 → SUCCESS, 0.60–0.89 → WARNING, < 0.60 → DANGER + +--- + +## 3. Regras de layout e responsividade + +### Princípio geral + +A janela tem tamanho mínimo de **800×600**. Todo layout deve funcionar bem nessa +dimensão e escalar corretamente ao aumentar. + +### O que usar + +| Situação | Valor correto | +|----------|--------------| +| Widget que deve preencher o espaço disponível | `Length::Fill` | +| Label ao lado de input em linha | `Length::FillPortion(3)` (label) + input com tamanho fixo pequeno ou `Fill` | +| Input numérico curto (índice, posição) | `Length::Fixed(90.0)` ou `Length::Fixed(110.0)` | +| Input de texto longo (nome, aba) | `Length::Fill` | +| Pick list de opções | `Length::Fill` | +| Modal/card centralizado com largura máxima | `.max_width(N)` + `Length::Fill` | +| Botões em linha que podem quebrar | `.wrap()` no `row![]` | +| Elemento que deve ter tamanho mínimo sem crescer | `Length::Shrink` | +| Células de tabela com scroll horizontal | `Length::Fixed(100.0)` mínimo | + +### O que **não** fazer + +- **Não use `Length::Fixed` em labels de formulário.** Labels devem usar + `FillPortion` para se adaptar ao espaço disponível. +- **Não use `Length::Fixed` em pick lists ou text inputs de texto livre.** + Use `Fill` para que se adaptem à largura do container pai. +- **Não coloque valores maiores que `max_width` em modais.** Use `.max_width(N)` + em vez de `Fixed(N)` para que o modal encolha em janelas menores. +- **Não deixe telas sem `scrollable`.** Toda tela com conteúdo vertical deve ser + envolvida em `scrollable()` para evitar clipping em janelas pequenas. +- **Não use `row![]` com muitos itens sem `.wrap()`.** Botões de ação e grupos + de controles devem usar `.wrap()` para quebrar linha quando não couberem. + +### Padrão de campo de formulário + +```rust +// Linha de campo: label proporcional + input +fn campo_row<'a>(label: &'a str, input: Element<'a, Message>) -> Element<'a, Message> { + row![ + text(label) + .size(13) + .color(t::TEXT_SECONDARY) + .width(Length::FillPortion(3)), // proporcional, não fixo + input, // input define seu próprio tamanho + ] + .spacing(10) + .align_y(Alignment::Center) + .into() +} +``` + +### Padrão de campo opcional com checkbox + +```rust +// Quando ativo: checkbox (FillPortion) + input +// Quando inativo: só o checkbox +if ativo { + row![ + cb.width(Length::FillPortion(3)), + text_input("...", &val) + .width(Length::Fixed(90.0)), // input numérico curto + ] + .spacing(10) + .align_y(Alignment::Center) + .into() +} else { + row![cb].into() +} +``` + +--- + +## 4. Estrutura de uma tela (screen) + +Toda tela segue o mesmo padrão de função pública `view`: + +```rust +pub fn view(app: &App) -> Element<'_, Message> { + // 1. Montar seções/componentes individuais + let secao_x = ...; + let secao_y = ...; + + // 2. Combinar em coluna principal + let conteudo = column![secao_x, secao_y] + .spacing(14) + .padding([20, 24]) + .width(Length::Fill); + + // 3. Envolver em scrollable + container de fundo + container(scrollable(conteudo)) + .style(t::fundo) + .width(Length::Fill) + .height(Length::Fill) + .into() +} +``` + +**Toda tela deve ter `scrollable` e `container` com `t::fundo` na raiz.** + +--- + +## 5. Componentes reutilizáveis + +### `modal::view_com_modal(conteudo, modal)` + +Envolve qualquer `Element` com um overlay de modal bloqueante. +Chamado em `app.rs` quando `self.modal.is_some()`. + +- Modal tem `max_width(420)` + `Length::Fill` para ser responsivo. +- Tipos disponíveis: `Informacao`, `Aviso`, `Erro`, `Confirmacao`, `InputTexto`. +- Disparar modal: usar os helpers em `app.rs` (`exibir_erro`, `exibir_aviso`, `exibir_confirmacao`). + +### `tabela_preview::tabela_preview(linhas)` + +Renderiza as primeiras N linhas do arquivo com cabeçalho estilo Excel (A, B, C...). + +- Células com `Fixed(100.0)` — tamanho fixo mínimo com scroll horizontal. +- A altura da área de scroll está fixada em `Fixed(160.0)` — intencional. +- Scroll horizontal via `scrollable::Direction::Horizontal`. + +### `paginacao::controles_paginacao(pagina, total, msg_anterior, msg_proxima)` + +Row de botões ◀ / "Página X / Y" / ▶. +Emite as mensagens passadas como parâmetro. + +--- + +## 6. Roteamento entre telas + +O roteamento é feito pelo enum `EstadoApp` em `app.rs`: + +| Estado | Tela renderizada | +|--------|-----------------| +| `Importando` | `screens/import.rs` | +| `SelecionandoAba` | `screens/selecionar_aba.rs` | +| `ConfigurandoColunas` | `screens/configuracao_colunas.rs` | +| `ConfirmandoIntervalo` | `screens/configuracao_colunas.rs` (mesmo view) | +| `ExibindoResultado(r)` | `screens/resultado.rs` | +| `GerenciandoLayouts` | `screens/layouts.rs` | +| `Analisando` | Spinner inline em `app.rs` | + +Transições são sempre via `Message` → `update()`. **Nunca altere `self.estado` +diretamente de dentro de uma tela.** + +O breadcrumb é renderizado automaticamente por `app.rs` para todos os estados +exceto `GerenciandoLayouts` e `Analisando`. + +--- + +## 7. Adicionando uma nova tela + +1. Crie `src/ui/screens/minha_tela.rs` com função `pub fn view(app: &App) -> Element<'_, Message>`. +2. Adicione `pub mod minha_tela;` em `src/ui/screens/mod.rs`. +3. Adicione a variante correspondente em `EstadoApp` (`app.rs`). +4. Adicione o arm no `match &self.estado` em `app.view()` (`app.rs`). +5. Adicione as mensagens necessárias em `message.rs`. +6. Trate as mensagens no `update()` de `app.rs`. + +--- + +## 8. Adicionando um novo componente + +1. Crie `src/ui/components/meu_componente.rs`. +2. Adicione `pub mod meu_componente;` em `src/ui/components/mod.rs`. +3. O componente deve ser uma função pura: recebe dados por parâmetro, retorna `Element<'_, Message>`. +4. Sem estado interno, sem `self`, sem acesso ao banco. + +--- + +## 9. Adicionando novos estilos ao tema + +- Sempre adicione em `theme.rs`. +- Siga o padrão dos helpers existentes: função que recebe `&Theme` e retorna o `Style` do widget. +- Para cores com alpha: use `Color { a: 0.N, ..CONSTANTE }` em vez de valores RGB manuais. +- Nomeie helpers de container como `nome_do_contexto`, botões como `btn_nome`, inputs como `input_nome`. + +--- + +## 10. Mensagens e estado assíncrono + +- Operações bloqueantes (I/O, análise) são sempre executadas em `Task::perform` com + `tokio::task::spawn_blocking`. +- O resultado retorna para `update()` via `Message`. +- O estado `EstadoApp::Analisando` é usado enquanto a operação está em background. +- `ResultadoPendente` é o tipo intermediário entre a thread de análise e a UI. + +**Não bloquear a thread principal da UI.** Qualquer operação lenta deve usar `Task`. diff --git a/src/ui/components/modal.rs b/src/ui/components/modal.rs index 8da49c9..f0f0792 100644 --- a/src/ui/components/modal.rs +++ b/src/ui/components/modal.rs @@ -138,7 +138,8 @@ fn caixa_modal<'a>( col = col.push(botoes); container(col.align_x(Alignment::Start)) - .width(Length::Fixed(420.0)) + .max_width(420) + .width(Length::Fill) .padding(24) .style(t::card) .into() diff --git a/src/ui/components/tabela_preview.rs b/src/ui/components/tabela_preview.rs index 38d2528..d7f7c26 100644 --- a/src/ui/components/tabela_preview.rs +++ b/src/ui/components/tabela_preview.rs @@ -22,7 +22,7 @@ pub fn tabela_preview(linhas: &[Vec]) -> Element<'_, Message> { .size(12) .color(t::TEXT_SECONDARY), ) - .width(Length::Fixed(120.0)) + .width(Length::Fixed(100.0)) .padding([4, 6]) .into() }) @@ -63,7 +63,7 @@ pub fn tabela_preview(linhas: &[Vec]) -> Element<'_, Message> { celula.to_string() }; container(text(truncado).font(Font::MONOSPACE).size(11).color(t::TEXT)) - .width(Length::Fixed(120.0)) + .width(Length::Fixed(100.0)) .padding([3, 6]) .into() }) diff --git a/src/ui/screens/configuracao_colunas.rs b/src/ui/screens/configuracao_colunas.rs index 9407b05..89f5c64 100644 --- a/src/ui/screens/configuracao_colunas.rs +++ b/src/ui/screens/configuracao_colunas.rs @@ -59,7 +59,7 @@ pub fn view(app: &App) -> Element<'_, Message> { text("Layout:") .size(13) .color(t::TEXT_SECONDARY) - .width(Length::Fixed(80.0)), + .width(Length::Shrink), pick_list(opcoes_layout, nome_layout_sel, { let layouts = app.layouts_salvos.clone(); move |nome_selecionado: String| { @@ -76,7 +76,7 @@ pub fn view(app: &App) -> Element<'_, Message> { }) .placeholder("— Selecionar layout —") .style(input_style_pick) - .width(Length::Fixed(260.0)), + .width(Length::Fill), ] .spacing(8) .align_y(Alignment::Center), @@ -251,7 +251,7 @@ fn view_csv(app: &App) -> Element<'_, Message> { Message::DelimitadorAlterado(c) }) .style(input_style_pick) - .width(Length::Fixed(200.0)) + .width(Length::Fill) .into(), ), campo_row( @@ -262,7 +262,7 @@ fn view_csv(app: &App) -> Element<'_, Message> { Message::EncodingAlterado ) .style(input_style_pick) - .width(Length::Fixed(200.0)) + .width(Length::Fill) .into(), ), campo_row( @@ -274,7 +274,7 @@ fn view_csv(app: &App) -> Element<'_, Message> { .unwrap_or(Message::Noop) }) .style(t::input_dark) - .width(Length::Fixed(80.0)) + .width(Length::Fixed(90.0)) .into(), ), secao_subtitulo("Mapeamento de colunas (índice base 0)"), @@ -287,7 +287,7 @@ fn view_csv(app: &App) -> Element<'_, Message> { .unwrap_or(Message::Noop) }) .style(t::input_dark) - .width(Length::Fixed(80.0)) + .width(Length::Fixed(90.0)) .into(), ), campo_row( @@ -299,7 +299,7 @@ fn view_csv(app: &App) -> Element<'_, Message> { .unwrap_or(Message::Noop) }) .style(t::input_dark) - .width(Length::Fixed(80.0)) + .width(Length::Fixed(90.0)) .into(), ), campo_indice_opcional_csv( @@ -341,7 +341,7 @@ fn view_xlsx(app: &App) -> Element<'_, Message> { text_input("Nome da aba", &c.aba) .on_input(Message::AbaXlsxAlterada) .style(t::input_dark) - .width(Length::Fixed(200.0)) + .width(Length::Fill) .into(), ) } else { @@ -354,7 +354,7 @@ fn view_xlsx(app: &App) -> Element<'_, Message> { "Aba", pick_list(app.abas_xlsx.clone(), aba_sel, Message::AbaXlsxAlterada) .style(input_style_pick) - .width(Length::Fixed(200.0)) + .width(Length::Fill) .into(), ) }; @@ -416,7 +416,7 @@ fn campo_row<'a>(label: &'a str, input: Element<'a, Message>) -> Element<'a, Mes text(label) .size(13) .color(t::TEXT_SECONDARY) - .width(Length::Fixed(220.0)), + .width(Length::FillPortion(3)), input, ] .spacing(10) @@ -437,11 +437,11 @@ fn campo_indice_opcional_csv( if ativo { row![ - cb.width(Length::Fixed(220.0)), + cb.width(Length::FillPortion(3)), text_input("0", &val_str) .on_input(move |s| { s.parse::().map(&msg_valor).unwrap_or(Message::Noop) }) .style(t::input_dark) - .width(Length::Fixed(80.0)), + .width(Length::Fixed(90.0)), ] .spacing(10) .align_y(Alignment::Center) @@ -461,7 +461,7 @@ fn campo_letra_linha<'a>( text_input("ex: B3", valor) .on_input(msg) .style(t::input_dark) - .width(Length::Fixed(100.0)) + .width(Length::Fixed(110.0)) .into(), ) } @@ -479,11 +479,11 @@ fn campo_letra_linha_opcional<'a>( if ativo { row![ - cb.width(Length::Fixed(220.0)), + cb.width(Length::FillPortion(3)), text_input("ex: B3", &val_str) .on_input(msg_valor) .style(t::input_dark) - .width(Length::Fixed(100.0)), + .width(Length::Fixed(110.0)), ] .spacing(10) .align_y(Alignment::Center) diff --git a/src/ui/screens/import.rs b/src/ui/screens/import.rs index a343f6a..cbc841f 100644 --- a/src/ui/screens/import.rs +++ b/src/ui/screens/import.rs @@ -1,7 +1,7 @@ use crate::ui::app::App; use crate::ui::message::Message; use crate::ui::theme as t; -use iced::widget::{button, column, container, pick_list, row, text}; +use iced::widget::{button, column, container, pick_list, row, scrollable, text}; use iced::{Alignment, Element, Length}; /// Tela de importação de arquivos. @@ -110,7 +110,7 @@ pub fn view(app: &App) -> Element<'_, Message> { text("Layout salvo:") .size(13) .color(t::TEXT_SECONDARY) - .width(Length::Fixed(110.0)), + .width(Length::Shrink), pick_list(opcoes_layout, nome_layout_sel, { let layouts = app.layouts_salvos.clone(); move |nome_selecionado: String| { @@ -173,7 +173,7 @@ pub fn view(app: &App) -> Element<'_, Message> { .width(Length::Fill); // ── Layout geral ────────────────────────────────────────────────────────── - container( + container(scrollable( column![ text("Importar Arquivo").size(20).color(t::TEXT), text("Selecione e configure sua planilha para análise") @@ -185,7 +185,7 @@ pub fn view(app: &App) -> Element<'_, Message> { .align_x(Alignment::Center) .padding([32, 20]) .width(Length::Fill), - ) + )) .style(t::fundo) .width(Length::Fill) .height(Length::Fill) diff --git a/src/ui/screens/resultado.rs b/src/ui/screens/resultado.rs index 2953e0c..16081cb 100644 --- a/src/ui/screens/resultado.rs +++ b/src/ui/screens/resultado.rs @@ -31,22 +31,27 @@ pub fn view<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Me let botoes_topo = row![ button("< Nova Análise") .on_press(Message::NovaAnalise) - .style(t::btn_secondary), - button("Reconfigurar Colunas") + .style(t::btn_secondary) + .width(Length::Shrink), + button("Reconfigurar") .on_press(Message::IrParaConfiguracaoColunas) - .style(t::btn_ghost), - button("Reanalisar Arquivo") + .style(t::btn_ghost) + .width(Length::Shrink), + button("Reanalisar") .on_press_maybe( app.caminho_arquivo .as_ref() .map(|_| Message::ReanalisarArquivo) ) - .style(t::btn_ghost), + .style(t::btn_ghost) + .width(Length::Shrink), button("Exportar PDF") .on_press(Message::ExportarPdf) - .style(t::btn_primary), + .style(t::btn_primary) + .width(Length::Shrink), ] - .spacing(8); + .spacing(8) + .wrap(); // ── Controle de itens por página ────────────────────────────────────────── let opcoes_por_pagina = row(OPCOES_PAGINA