- Replaced the `renderizar` function in `layouts.rs` with a new `view` function using Iced for a more modern UI approach. - Introduced a new `view_secao_layouts` function to handle the display of saved layouts. - Updated the `resultado.rs` file to use Iced for rendering the results screen, including buttons for actions and pagination controls. - Created a new `selecionar_aba.rs` file for the selection of XLSX sheet tabs, implementing a preview feature. - Removed old rendering functions and replaced them with Iced components for better performance and maintainability. - Added design tokens in `design_tokens.json` for consistent styling across the application. - Created a mockup HTML file to visualize the UI design.
505 lines
17 KiB
Markdown
505 lines
17 KiB
Markdown
# Migração egui → iced: Estado e Referência
|
|
|
|
**Branch:** `change-ui`
|
|
**Versão iced:** 0.13.1
|
|
**Última atualização:** 04/03/2026
|
|
**Status:** ✅ **COMPILANDO** — `cargo build` passa sem erros (5 warnings não-bloqueantes, todos em camadas intocáveis)
|
|
|
|
---
|
|
|
|
## Estado Atual
|
|
|
|
### O que foi feito (100% completo)
|
|
|
|
| Arquivo | Status | Descrição |
|
|
|---|---|---|
|
|
| `Cargo.toml` | ✅ | `eframe`/`egui`/`image` removidos; `iced 0.13` + `tokio` adicionados |
|
|
| `src/main.rs` | ✅ | Reescrito com `iced::application(...)` |
|
|
| `src/ui/mod.rs` | ✅ | `pub mod app; pub mod components; pub mod message; pub mod screens;` |
|
|
| `src/ui/message.rs` | ✅ | Enum `Message` completo + enum `ResultadoPendente` |
|
|
| `src/ui/app.rs` | ✅ | Reescrito para iced (Elm architecture) — `App`, `update()`, `view()` |
|
|
| `src/ui/screens/mod.rs` | ✅ | Re-exports + `pub fn indice_para_letra(idx: usize) -> String` |
|
|
| `src/ui/screens/import.rs` | ✅ | Tela de importação — pick_list de layout usa `LayoutSelecionado` |
|
|
| `src/ui/screens/selecionar_aba.rs` | ✅ | Tela de seleção de aba XLSX |
|
|
| `src/ui/screens/configuracao_colunas.rs` | ✅ | Tela de configuração — pick_list de layout usa `LayoutSelecionado` |
|
|
| `src/ui/screens/resultado.rs` | ✅ | Tela de resultado — botão "Nova Análise" usa `Message::NovaAnalise` |
|
|
| `src/ui/screens/layouts.rs` | ✅ | Tela de gerenciamento de layouts |
|
|
| `src/ui/components/mod.rs` | ✅ | `pub mod modal; pub mod paginacao; pub mod tabela_preview;` |
|
|
| `src/ui/components/modal.rs` | ✅ | Overlay real com `stack!` + `mouse_area` |
|
|
| `src/ui/components/tabela_preview.rs` | ✅ | Tabela com scroll horizontal e cabeçalho estilo Excel |
|
|
| `src/ui/components/paginacao.rs` | ✅ | Controles reutilizáveis de paginação |
|
|
|
|
### O que NÃO foi alterado (intocado por design)
|
|
|
|
- `src/domain/` — entidades, serviços, erros
|
|
- `src/application/` — casos de uso
|
|
- `src/infrastructure/` — leitores CSV/XLSX, gerador PDF, SQLite
|
|
|
|
---
|
|
|
|
## Arquitetura da UI
|
|
|
|
### Padrão Elm (iced)
|
|
|
|
```
|
|
App (estado) ──→ view(&self) ──→ Element (widgets renderizados)
|
|
↑ ↓
|
|
│ usuário interage
|
|
│ ↓
|
|
└── update(&mut self, msg) ←── Message (enum)
|
|
retorna Task<Message>
|
|
```
|
|
|
|
- `view()` é **pura e somente leitura** — nunca muta estado
|
|
- Toda mutação acontece **exclusivamente** em `update()`
|
|
- Background tasks retornam via `Task::perform(async { ... }, Message::Variante)`
|
|
|
|
### Estrutura de arquivos
|
|
|
|
```
|
|
src/
|
|
├── main.rs
|
|
└── ui/
|
|
├── mod.rs
|
|
├── app.rs (struct App + update + view)
|
|
├── message.rs (enum Message + ResultadoPendente)
|
|
├── screens/
|
|
│ ├── mod.rs (re-exports + indice_para_letra)
|
|
│ ├── import.rs
|
|
│ ├── selecionar_aba.rs
|
|
│ ├── configuracao_colunas.rs
|
|
│ ├── resultado.rs
|
|
│ └── layouts.rs
|
|
└── components/
|
|
├── mod.rs
|
|
├── modal.rs
|
|
├── tabela_preview.rs
|
|
└── paginacao.rs
|
|
```
|
|
|
|
---
|
|
|
|
## Diferenças Críticas da API — iced 0.13
|
|
|
|
Estas diferenças causaram erros de compilação e **devem ser lembradas** em qualquer adição futura:
|
|
|
|
### 1. `Command` foi renomeado para `Task`
|
|
|
|
```rust
|
|
// ❌ NÃO EXISTE em iced 0.13
|
|
use iced::Command;
|
|
Command::none()
|
|
|
|
// ✅ CORRETO
|
|
use iced::Task;
|
|
Task::none()
|
|
Task::perform(future, mapper)
|
|
```
|
|
|
|
Todos os retornos de `update()` são `Task<Message>`.
|
|
|
|
### 2. `.align_items()` foi dividido
|
|
|
|
```rust
|
|
// ❌ NÃO EXISTE em iced 0.13
|
|
row![...].align_items(Alignment::Center)
|
|
|
|
// ✅ CORRETO — Row usa align_y, Column usa align_x
|
|
row![...].align_y(Alignment::Center)
|
|
column![...].align_x(Alignment::Center)
|
|
```
|
|
|
|
### 3. `.center_x()` / `.center_y()` exigem argumento `Length`
|
|
|
|
```rust
|
|
// ❌ NÃO COMPILA
|
|
container(...).center_x().center_y()
|
|
|
|
// ✅ CORRETO
|
|
container(...).center_x(Length::Fill).center_y(Length::Fill)
|
|
```
|
|
|
|
### 4. `iced::clipboard::write` retorna `Task<Message>`
|
|
|
|
```rust
|
|
// Pode ser retornado diretamente de update():
|
|
return iced::clipboard::write(texto);
|
|
```
|
|
|
|
### 5. Entry point usa API funcional, não trait `Application`
|
|
|
|
```rust
|
|
// main.rs
|
|
fn main() -> iced::Result {
|
|
iced::application("Título", App::update, App::view)
|
|
.window(iced::window::Settings { ... })
|
|
.run_with(App::new)
|
|
}
|
|
|
|
// App::new retorna (Self, Task<Message>)
|
|
pub fn new() -> (Self, Task<Message>) { ... }
|
|
```
|
|
|
|
### 6. Lifetimes em funções que retornam `Element`
|
|
|
|
Sempre usar `Element<'_, Message>` (não `Element<Message>`) em funções que recebem referências:
|
|
|
|
```rust
|
|
// ❌ Gera warning mismatched_lifetime_syntaxes
|
|
pub fn view(app: &App) -> Element<Message>
|
|
|
|
// ✅ CORRETO
|
|
pub fn view(app: &App) -> Element<'_, Message>
|
|
```
|
|
|
|
**Armadilha com Vec local:** criar `Vec<T>` local e passar `&vec` para função que retorna `Element<'a>` causa E0515. Solução: filtrar diretamente de dados que vivem no `&app`.
|
|
|
|
### 7. Borrow checker com `Arc<Mutex<Connection>>`
|
|
|
|
```rust
|
|
// ❌ Mantém borrow imutável de &self enquanto tenta &mut self
|
|
if let Some(conn_arc) = &self.conn {
|
|
let conn = conn_arc.lock().unwrap();
|
|
self.exibir_erro(...); // ERRO: borrow ativo
|
|
}
|
|
|
|
// ✅ Clonar o Arc primeiro (O(1), não clona a Connection)
|
|
let conn_arc = self.conn.as_ref().map(Arc::clone);
|
|
if let Some(conn_arc) = conn_arc {
|
|
let conn = conn_arc.lock().unwrap();
|
|
drop(conn); // liberar antes de &mut self
|
|
self.exibir_erro(...);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Componentes Implementados
|
|
|
|
### `App` (src/ui/app.rs)
|
|
|
|
**Estado:**
|
|
```rust
|
|
pub struct App {
|
|
pub estado: EstadoApp,
|
|
pub conn: Option<Arc<Mutex<Connection>>>,
|
|
pub banco_foi_recriado: bool,
|
|
pub notas_importadas: Vec<Nota>,
|
|
pub caminho_arquivo: Option<PathBuf>, // ← populado para CSV e XLSX
|
|
pub nome_arquivo: String,
|
|
pub tipo_arquivo_atual: TipoArquivo,
|
|
pub layout_csv_atual: LayoutCsv,
|
|
pub layout_xlsx_atual: LayoutXlsx,
|
|
pub nome_layout_atual: String,
|
|
pub abas_xlsx: Vec<String>,
|
|
pub layouts_salvos: Vec<Layout>,
|
|
pub modal: Option<EstadoModal>,
|
|
pub avisos_importacao: Option<ResumoAvisos>,
|
|
pub pagina_faltantes: usize,
|
|
pub pagina_duplicatas: usize,
|
|
pub itens_por_pagina: usize,
|
|
pub preview_arquivo: Option<Vec<Vec<String>>>,
|
|
pub resultado_anterior: Option<ResultadoAnalise>,
|
|
}
|
|
```
|
|
|
|
**Máquina de estados (`EstadoApp`):**
|
|
```
|
|
Importando
|
|
→ ArquivoSelecionado (CSV) → ConfigurandoColunas
|
|
→ ArquivoSelecionado (XLSX) → SelecionandoAba → ConfigurandoColunas
|
|
ConfigurandoColunas
|
|
→ ExecutarImportacao → Analisando
|
|
Analisando
|
|
→ AnaliseCompleta (ok, sem excessivos) → ExibindoResultado
|
|
→ AnaliseCompleta (excessivos) → ConfirmandoIntervalo (+ modal)
|
|
ConfirmandoIntervalo
|
|
→ ModalConfirmado (ConfirmarExpansaoFaltantes) → Analisando → ExibindoResultado
|
|
→ ModalCancelado → ConfigurandoColunas
|
|
ExibindoResultado
|
|
→ ReanalisarArquivo → Analisando
|
|
→ IrParaConfiguracaoColunas → ConfigurandoColunas
|
|
→ NovaAnalise (modal) → Importando (após confirmação)
|
|
GerenciandoLayouts
|
|
→ IrParaImportacao → Importando
|
|
```
|
|
|
|
**Modal (`EstadoModal` / `AcaoModal`):**
|
|
```rust
|
|
pub enum EstadoModal {
|
|
Informacao { titulo: String, mensagem: String }, // definido, não usado ainda
|
|
Aviso { titulo: String, mensagem: String },
|
|
Erro { titulo: String, mensagem: String },
|
|
Confirmacao { titulo: String, mensagem: String, acao: AcaoModal },
|
|
InputTexto { titulo: String, mensagem: String, texto: String, acao: AcaoModal },
|
|
}
|
|
|
|
pub enum AcaoModal {
|
|
ConfirmarExpansaoFaltantes,
|
|
ConfirmarExclusaoLayout(i64),
|
|
SobrescreverLayout(Layout),
|
|
ConfirmarNovaAnalise,
|
|
SalvarLayoutConfig,
|
|
}
|
|
```
|
|
|
|
**Comportamento do `ModalCancelado`:**
|
|
- Fecha o modal (`self.modal = None`)
|
|
- Se o estado for `ConfirmandoIntervalo`, **também** reseta para `ConfigurandoColunas`
|
|
|
|
### `Message` (src/ui/message.rs) — enum completo
|
|
|
|
```rust
|
|
pub enum Message {
|
|
// Inicialização
|
|
BancoInicializado(Result<(Arc<Mutex<Connection>>, bool, Vec<Layout>), String>),
|
|
LayoutsRecarregados(Vec<Layout>),
|
|
|
|
// Navegação
|
|
IrParaImportacao, IrParaConfiguracaoColunas, IrParaLayouts, Voltar,
|
|
|
|
// Arquivo
|
|
SelecionarArquivo,
|
|
ArquivoSelecionado(PathBuf),
|
|
AbaSelecionada(String),
|
|
AbaxlsxCarregadas { caminho: PathBuf, abas: Vec<String>, layout_xlsx: LayoutXlsx, nome_layout: String },
|
|
XlsxErroAoCarregar(String),
|
|
|
|
// Background
|
|
AnaliseCompleta(ResultadoPendente),
|
|
|
|
// Config CSV
|
|
DelimitadorAlterado(char), EncodingAlterado(String), LinhaCabecalhoAlterada(usize),
|
|
IndiceNumeroAlterado(usize), IndiceSerieAlterado(usize),
|
|
IndiceValorToggle(bool), IndiceValorAlterado(usize),
|
|
IndiceDataToggle(bool), IndiceDataAlterado(usize),
|
|
IndiceDocTipoToggle(bool), IndiceDocTipoAlterado(usize),
|
|
|
|
// Config XLSX
|
|
AbaXlsxAlterada(String),
|
|
PosNumeroAlterada(String), PosSerieAlterada(String),
|
|
PosValorToggle(bool), PosValorAlterada(String),
|
|
PosDataToggle(bool), PosDataAlterada(String),
|
|
PosDocTipoToggle(bool), PosDocTipoAlterada(String),
|
|
|
|
// Análise
|
|
ExecutarImportacao, ReanalisarArquivo,
|
|
ConfirmarExpansaoFaltantes,
|
|
CancelarExpansao, // arm existe em update() mas nenhum widget o dispara diretamente
|
|
NovaAnalise, // abre modal de confirmação (AcaoModal::ConfirmarNovaAnalise)
|
|
|
|
// Resultado
|
|
PaginaFaltantesAlterada(usize), PaginaDuplicatasAlterada(usize),
|
|
ItensPorPaginaAlterado(usize),
|
|
CopiarFaltantes(ChaveSerie), CopiarDuplicatas(ChaveSerie),
|
|
ExportarPdf, PdfExportado(Result<PathBuf, String>),
|
|
|
|
// Layouts
|
|
LayoutSelecionado(i64), // aplica layout (chama aplicar_layout) + atualiza nome
|
|
SalvarLayout, // abre modal InputTexto
|
|
NomeLayoutAlterado(String),
|
|
ExcluirLayout(i64), ExclusaoConfirmada(i64),
|
|
ExportarLayoutJson(i64), ImportarLayoutJson, LayoutJsonImportado(String),
|
|
SobrescreverLayout(Layout),
|
|
|
|
// Modal
|
|
ModalTextoAlterado(String), ModalConfirmado, ModalCancelado,
|
|
|
|
Noop,
|
|
}
|
|
```
|
|
|
|
### `modal.rs` (src/ui/components/modal.rs)
|
|
|
|
Overlay real: `stack![conteudo, overlay_escuro]`. O `mouse_area` captura cliques fora da caixa e dispara `ModalCancelado`.
|
|
|
|
```rust
|
|
pub fn view_com_modal<'a>(
|
|
conteudo: Element<'a, Message>,
|
|
modal: &'a EstadoModal,
|
|
) -> Element<'a, Message>
|
|
```
|
|
|
|
Botões da caixa modal:
|
|
- Sempre: **"Fechar"** → `ModalCancelado`
|
|
- Se `com_confirmar`: **"Confirmar"** → `ModalConfirmado`
|
|
|
|
### `tabela_preview.rs` (src/ui/components/tabela_preview.rs)
|
|
|
|
Cabeçalho com letras estilo Excel (A, B, C...) + índice numérico. Scroll horizontal. Trunca células em 30 caracteres. Altura fixa de 160px.
|
|
|
|
### `paginacao.rs` (src/ui/components/paginacao.rs)
|
|
|
|
```rust
|
|
pub fn controles_paginacao(
|
|
pagina_atual: usize,
|
|
total_paginas: usize,
|
|
msg_anterior: Message,
|
|
msg_proximo: Message,
|
|
) -> Element<'static, Message>
|
|
```
|
|
|
|
Botões ◀ / ▶ desabilitados automaticamente na primeira/última página via `on_press_maybe`.
|
|
|
|
---
|
|
|
|
## Regras de Seleção de Layout (pick_list)
|
|
|
|
**Em todas as telas** que exibem um `pick_list` de layouts, o handler **deve** usar `LayoutSelecionado(id)`, não `NomeLayoutAlterado`:
|
|
|
|
```rust
|
|
pick_list(opcoes_layout, nome_sel, {
|
|
let layouts = app.layouts_salvos.clone();
|
|
move |nome: String| {
|
|
if let Some(id) = layouts.iter().find(|l| l.nome() == nome).and_then(|l| l.id()) {
|
|
Message::LayoutSelecionado(id)
|
|
} else {
|
|
Message::NomeLayoutAlterado(nome) // fallback (nunca deve ocorrer com layouts do banco)
|
|
}
|
|
}
|
|
})
|
|
```
|
|
|
|
Isso vale para: `import.rs`, `configuracao_colunas.rs`. A diferença:
|
|
- `NomeLayoutAlterado` — só atualiza `self.nome_layout_atual` (string), **não preenche o formulário**
|
|
- `LayoutSelecionado(id)` — chama `aplicar_layout()`, que preenche `layout_csv_atual` ou `layout_xlsx_atual`
|
|
|
|
---
|
|
|
|
## Fluxo XLSX — Detalhe Crítico
|
|
|
|
### Onde `caminho_arquivo` é populado
|
|
|
|
Para **CSV**: em `processar_arquivo_selecionado()`, logo após detectar a extensão.
|
|
|
|
Para **XLSX**: o caminho fica dentro do estado `SelecionandoAba { caminho }` e é copiado para `self.caminho_arquivo` **somente quando o usuário seleciona uma aba** (handler `AbaSelecionada`).
|
|
|
|
```
|
|
ArquivoSelecionado(xlsx)
|
|
→ AbaxlsxCarregadas
|
|
→ aba já definida no layout e existe no arquivo?
|
|
Sim → caminho_arquivo setado aqui → disparar_importacao()
|
|
Não → estado = SelecionandoAba { caminho } ← caminho_arquivo ainda None aqui
|
|
→ AbaSelecionada(aba)
|
|
→ caminho_arquivo = Some(caminho) ← só aqui é setado
|
|
→ estado permanece SelecionandoAba
|
|
→ IrParaConfiguracaoColunas
|
|
→ estado = ConfigurandoColunas
|
|
→ tem_arquivo = true ✓
|
|
```
|
|
|
|
**Consequência:** nunca verificar `app.caminho_arquivo.is_some()` como condição de habilitação antes do usuário ter selecionado uma aba no fluxo XLSX.
|
|
|
|
---
|
|
|
|
## Background Tasks
|
|
|
|
Todas as operações pesadas usam `Task::perform`:
|
|
|
|
```rust
|
|
// Padrão para operações síncronas em background:
|
|
Task::perform(
|
|
async move {
|
|
tokio::task::spawn_blocking(move || {
|
|
// código síncrono (rusqlite, calamine, etc.)
|
|
})
|
|
.await
|
|
.unwrap_or_else(|e| ResultadoPendente::Erro(e.to_string()))
|
|
},
|
|
Message::AnaliseCompleta,
|
|
)
|
|
```
|
|
|
|
| Operação | Message de retorno |
|
|
|---|---|
|
|
| Abrir banco + migrations + listar layouts | `BancoInicializado` |
|
|
| Listar layouts após salvar/excluir | `LayoutsRecarregados` |
|
|
| Listar abas de arquivo XLSX | `AbaxlsxCarregadas` / `XlsxErroAoCarregar` |
|
|
| Importar CSV/XLSX + analisar | `AnaliseCompleta` |
|
|
| Expandir análise com faltantes | `AnaliseCompleta` |
|
|
| Exportar PDF | `PdfExportado` |
|
|
| Exportar layout JSON | `Noop` (salva diretamente) |
|
|
| Importar layout JSON | `LayoutJsonImportado` |
|
|
|
|
**Diálogos de arquivo** usam `rfd::AsyncFileDialog` (não bloqueia):
|
|
```rust
|
|
Task::perform(
|
|
async {
|
|
rfd::AsyncFileDialog::new()
|
|
.add_filter("Planilhas", &["csv", "xlsx", "xls"])
|
|
.pick_file()
|
|
.await
|
|
.map(|h| h.path().to_path_buf())
|
|
},
|
|
|r| r.map(Message::ArquivoSelecionado).unwrap_or(Message::Noop),
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## Warnings Existentes (não-bloqueantes)
|
|
|
|
| Warning | Local | Situação |
|
|
|---|---|---|
|
|
| `field data is never read` | `domain/nota.rs` | Domínio intocável — ignorar |
|
|
| `fields minimo/maximo never read` | `domain/resultado_analise.rs` | Domínio intocável — ignorar |
|
|
| `method sem_inconsistencias never used` | `domain/resultado_analise.rs` | Domínio intocável — ignorar |
|
|
| `variant Informacao never constructed` | `ui/app.rs` | Mantido para uso futuro (modal informativo) |
|
|
| `variant CancelarExpansao never constructed` | `ui/message.rs` | O arm existe em `update()`, mas nenhum widget o dispara; o cancelamento do intervalo ocorre via `ModalCancelado` |
|
|
|
|
---
|
|
|
|
## O Que Falta Implementar
|
|
|
|
### Bugs/lacunas conhecidos
|
|
|
|
1. **`selecionar_aba.rs` — botões sem diferenciação visual entre aba selecionada e não selecionada** — ambos os branches do `if selecionada` são idênticos (sem estilo diferente). A aba ativa deveria ter destaque visual.
|
|
|
|
2. **`selecionar_aba.rs` — layout não é aplicável nessa tela** — se o usuário chegou aqui sem ter selecionado um layout previamente, não há como selecionar um layout antes de avançar para ConfigurandoColunas. Considerar adicionar um `pick_list` de layouts na tela `SelecionandoAba`.
|
|
|
|
3. **Sem tema dark/light** — usa o tema padrão do sistema. Para adicionar:
|
|
```rust
|
|
// Em main.rs: encadear .theme(|app, _| app.tema.clone())
|
|
// Em App: campo pub tema: iced::Theme
|
|
// Mensagem: Message::TemaAlterado(iced::Theme)
|
|
```
|
|
|
|
4. **Sem drag-and-drop de arquivos** — importação tem apenas o botão de seleção. Verificar suporte nativo em iced 0.13 antes de implementar.
|
|
|
|
5. **Sem filtro/busca nos resultados** — sem `text_input` para filtrar `faltantes_por_serie`.
|
|
|
|
6. **`EstadoModal::Informacao` nunca é usado** — definido mas sem chamadas. Remover ou usar.
|
|
|
|
### Funcionalidades futuras (Fase 6 do plano original)
|
|
|
|
- Histórico de análises (nova tela + schema SQLite v4)
|
|
- Gráficos de completude por série
|
|
- Exportação para Excel/CSV
|
|
|
|
---
|
|
|
|
## Como Rodar
|
|
|
|
```bash
|
|
# Verificar sem compilar binário (rápido)
|
|
cargo check
|
|
|
|
# Compilar (primeira vez ~1 min por baixar iced + wgpu)
|
|
cargo build
|
|
|
|
# Executar em modo debug
|
|
cargo run
|
|
|
|
# Build de release (otimizado, sem console no Windows)
|
|
cargo build --release
|
|
```
|
|
|
|
---
|
|
|
|
## Referências
|
|
|
|
- [iced 0.13 changelog](https://github.com/iced-rs/iced/blob/master/CHANGELOG.md)
|
|
- [iced exemplos oficiais](https://github.com/iced-rs/iced/tree/master/examples)
|
|
- [rfd AsyncFileDialog](https://docs.rs/rfd/latest/rfd/struct.AsyncFileDialog.html)
|
|
- [iced::clipboard::write](https://docs.rs/iced/0.13.1/iced/clipboard/fn.write.html)
|