Files
comparador-notas/MIGRATION_ICED.md
T
2026-03-04 09:43:43 -03:00

676 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plano de Migração: egui → iced
**Branch:** `change-ui`
**Data:** 04/03/2026
**Versão iced alvo:** 0.13
---
## 1. Escopo da Migração
### O que muda
Apenas a camada `src/ui/` é reescrita. Nenhuma outra camada é tocada.
| Camada | Status na migração |
|-------------------|--------------------|
| `src/domain/` | Sem alterações |
| `src/application/`| Sem alterações |
| `src/infrastructure/` | Sem alterações |
| `src/ui/` | Reescrita completa |
| `src/main.rs` | Reescrito (entry point muda) |
| `Cargo.toml` | Substituição de dependências |
---
## 2. Mudanças no Cargo.toml
### Remover
```toml
eframe = "0.31"
egui = "0.31"
image = { version = "0.25", default-features = false, features = ["ico"] }
```
### Adicionar
```toml
iced = { version = "0.13", features = ["tokio", "image"] }
tokio = { version = "1", features = ["full"] }
```
> `rfd` permanece. Continua sendo usado para diálogos de arquivo nativos.
> `image` pode ser removido — iced carrega ícones diretamente de bytes.
---
## 3. Arquitetura da UI em iced
### Modelo mental (Elm Architecture)
```
Estado (Model) → view() → Element (o que é renderizado)
Interaction (usuário clica/digita)
Message (enum tipado)
update(msg) → muta o Estado
```
Não existe `&mut self` dentro de closures de renderização. A view é uma função pura que lê o estado e retorna `Element`. Mutações só ocorrem em `update()`.
### Estrutura de arquivos proposta
```
src/
├── main.rs (entry point iced)
├── ui/
│ ├── mod.rs
│ ├── app.rs (struct App, impl Application)
│ ├── message.rs (enum Message — todos os eventos)
│ ├── screens/
│ │ ├── mod.rs
│ │ ├── import.rs (view da tela de importação)
│ │ ├── selecionar_aba.rs (view da seleção de aba XLSX)
│ │ ├── configuracao_colunas.rs (view do mapeamento de colunas)
│ │ ├── resultado.rs (view dos resultados)
│ │ └── layouts.rs (view do gerenciamento de layouts)
│ └── components/
│ ├── mod.rs
│ ├── modal.rs (componente reutilizável de modal)
│ ├── tabela_preview.rs (componente de pré-visualização)
│ └── paginacao.rs (componente reutilizável de paginação)
```
---
## 4. Mapeamento: egui atual → iced equivalente
### 4.1 Estado global (`app.rs`)
**egui atual:**
```rust
pub struct App {
pub estado: EstadoApp, // enum de tela ativa
pub modal: Modal, // modal hand-rolled
pub resultado_pendente: Option<mpsc::Receiver<ResultadoPendente>>,
// ... 15 campos
}
impl eframe::App for App {
fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) { ... }
}
```
**iced equivalente:**
```rust
pub struct App {
pub estado: EstadoApp,
pub modal: Option<EstadoModal>,
// ... mesmos campos de dados
}
impl iced::Application for App {
type Message = Message;
type Executor = iced::executor::Tokio; // async nativo
type Theme = iced::Theme;
type Flags = ();
fn new(_flags: ()) -> (Self, Command<Message>) {
let app = App::default();
(app, Command::perform(inicializar_banco(), Message::BancoInicializado))
}
fn title(&self) -> String { "Comparador de Notas".into() }
fn update(&mut self, message: Message) -> Command<Message> { ... }
fn view(&self) -> Element<Message> { ... }
}
```
**Diferença principal:** `update()` recebe `Message` em vez de operar diretamente sobre eventos de UI. `view()` é somente leitura (`&self`), sem mutações.
---
### 4.2 Enum `EstadoApp` — sem mudanças necessárias
O enum pode ser mantido idêntico:
```rust
pub enum EstadoApp {
Importando,
SelecionandoAba { abas: Vec<String>, caminho: PathBuf },
ConfigurandoColunas,
ExibindoResultado(ResultadoAnalise),
ConfirmandoIntervalo { pre: ResultadoPreAnalise },
GerenciandoLayouts,
Analisando,
}
```
A diferença é que a navegação entre estados ocorre em `update()`, não dentro de render functions.
---
### 4.3 Sistema de mensagens (`message.rs`)
Toda interação do usuário vira uma variante do enum `Message`. Este é o arquivo central da UI em iced.
```rust
pub enum Message {
// --- Navegação ---
IrParaImportacao,
IrParaConfiguracaoColunas,
IrParaLayouts,
Voltar,
// --- Arquivo ---
SelecionarArquivo, // abre rfd
ArquivoSelecionado(PathBuf),
AbaSelecionada(String),
// --- Background tasks ---
AnaliseCompleta(ResultadoPendente), // resultado do tokio task
// --- Configuração CSV ---
DelimitadorAlterado(char),
EncodingAlterado(String),
LinhaCabecalhoAlterada(usize),
IndiceNumeroAlterado(usize),
IndiceSerieAlterado(usize),
IndiceValorToggle(bool),
IndiceValorAlterado(usize),
IndiceDataToggle(bool),
IndiceDataAlterada(usize),
IndiceDocTipoToggle(bool),
IndiceDocTipoAlterado(usize),
// --- Configuração 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,
// --- Resultado ---
PaginaFaltantesAlterada(usize),
PaginaDuplicatasAlterada(usize),
ItensPorPaginaAlterado(usize),
CopiarFaltantes(ChaveSerie),
CopiarDuplicatas(ChaveSerie),
ExportarPdf,
PdfExportado(Result<PathBuf, String>),
// --- Layouts ---
LayoutSelecionado(i64),
SalvarLayout,
NomeLayoutAlterado(String),
ExcluirLayout(i64),
ExclusaoConfirmada(i64),
ExportarLayoutJson(i64),
ImportarLayoutJson,
LayoutJsonImportado(String), // conteúdo do arquivo lido
SobrescreverLayout(Layout),
// --- Modal ---
ModalTextoAlterado(String),
ModalConfirmado,
ModalCancelado,
// --- Banco ---
BancoInicializado(Result<(Connection, bool), String>),
LayoutsRecarregados(Vec<Layout>),
}
```
---
### 4.4 Background tasks — substituição do `mpsc::channel`
**egui atual (manual):**
```rust
// Spawn thread + channel + polling no update()
let (tx, rx) = mpsc::channel();
app.resultado_pendente = Some(rx);
std::thread::spawn(move || {
let res = executar_trabalho();
let _ = tx.send(res);
});
// No update(): try_recv() + ctx.request_repaint()
```
**iced equivalente:**
```rust
// Em update(), retornar um Command que executa async e produz Message
Command::perform(
async move {
let importado = importar_csv(&caminho, &layout).await;
// ... pre_analisar, expandir_analise
ResultadoPendente::Concluido { ... }
},
Message::AnaliseCompleta
)
```
Sem `mpsc`, sem `request_repaint()`, sem polling. O iced gerencia o executor.
**Funções que precisam de wrapper async:**
- `importar_csv` → já é síncrona, wrappear em `tokio::task::spawn_blocking`
- `importar_xlsx` → idem
- `pre_analisar` → idem
- `expandir_analise` → idem
- `exportar_pdf` → idem
---
### 4.5 Sistema de modal
**egui atual:** `Window::anchor(CENTER_CENTER)` simulando modal — não é bloqueante de fato.
**iced equivalente:** Overlay real usando `iced::widget::modal` (disponível via `iced_aw` crate) ou implementação própria com `Stack` + `Container` centralizado:
```rust
// Componente Modal reutilizável
pub enum EstadoModal {
Informacao { titulo: String, mensagem: String },
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 },
}
// No view():
fn view_com_modal<'a>(conteudo: Element<'a, Message>, modal: &EstadoModal) -> Element<'a, Message> {
// Stack: conteúdo abaixo + overlay escuro + janela modal no centro
iced::widget::stack![
conteudo,
mouse_area(
container(view_modal(modal))
.width(Length::Fill)
.height(Length::Fill)
.style(|_| container::Style {
background: Some(Color::from_rgba(0.0, 0.0, 0.0, 0.5).into()),
..Default::default()
})
.center(Length::Fill)
).on_press(Message::ModalCancelado)
].into()
}
```
Este modal é **real**: a camada escura captura cliques, não sendo possível interagir com o conteúdo atrás.
---
### 4.6 Drag-and-drop de arquivos
**egui atual:** Não suportado. Usuário precisa usar o botão "Selecionar arquivo".
**iced equivalente:**
```rust
// No view() da tela de importação:
iced::widget::drop_zone(
container(text("Arraste um arquivo CSV ou XLSX aqui"))
.center(Length::Fill)
.style(estilo_drop_zone)
)
.on_drop(|paths| {
paths.into_iter().next()
.map(Message::ArquivoSelecionado)
.unwrap_or(Message::Noop)
})
```
> `iced::widget::drop_zone` está disponível a partir do iced 0.13.
---
### 4.7 Temas (dark/light mode)
**egui atual:** Sem suporte a temas. Usa o tema padrão da plataforma sem controle programático fácil.
**iced equivalente:**
```rust
fn theme(&self) -> iced::Theme {
match self.tema {
Tema::Claro => iced::Theme::Light,
Tema::Escuro => iced::Theme::Dark,
Tema::Sistema => iced::Theme::default(), // detecta preferência do SO
}
}
```
Adicionar ao estado:
```rust
pub tema: Tema,
```
E uma mensagem:
```rust
Message::TemaAlterado(Tema),
```
---
### 4.8 Breadcrumb de etapas
**egui atual:**
```rust
egui::TopBottomPanel::top("breadcrumb").show(ctx, |ui| { ... });
```
**iced equivalente:**
```rust
fn view_breadcrumb(passo_ativo: usize) -> Element<'static, Message> {
let passos = ["Arquivo", "Colunas", "Resultado"];
row(passos.iter().enumerate().map(|(i, label)| {
let n = i + 1;
let texto = format!("{}. {}", n, label);
let estilo = if n == passo_ativo { text::Style::default().color(COR_ATIVO) }
else if n < passo_ativo { text::Style::default() }
else { text::Style::default().color(COR_FRACO) };
// ... separador " " entre itens
}))
.into()
}
```
---
### 4.9 Tabela de pré-visualização
**egui atual:** `egui::Grid` com `end_row()` manual, `id_salt` obrigatório.
**iced equivalente:** Componente reutilizável via `iced::widget::scrollable` + `column`/`row`:
```rust
pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<Message> {
let num_colunas = linhas.iter().map(|l| l.len()).max().unwrap_or(0);
let cabecalho = row(
(0..num_colunas).map(|i| {
text(format!("{} ({})", indice_para_letra(i), i))
.font(Font::MONOSPACE)
.size(12)
.width(Length::Fixed(120.0))
.into()
})
);
let linhas_view = linhas.iter().map(|linha| {
row((0..num_colunas).map(|col| {
let celula = linha.get(col).map(|s| s.as_str()).unwrap_or("");
let truncado = truncar(celula, 30);
text(truncado).font(Font::MONOSPACE).size(11).width(Length::Fixed(120.0)).into()
})).into()
});
scrollable(
column(std::iter::once(cabecalho.into()).chain(linhas_view))
)
.direction(scrollable::Direction::Both { ... })
.into()
}
```
Sem `id_salt`, sem `end_row()`.
---
### 4.10 Paginação
**egui atual:** Código duplicado em `renderizar_faltantes` e `renderizar_duplicatas`, estado `pagina_faltantes`/`pagina_duplicatas` separado no `App`.
**iced equivalente:** Componente reutilizável:
```rust
pub fn controles_paginacao(
pagina_atual: usize,
total_paginas: usize,
msg_anterior: Message,
msg_proximo: Message,
) -> Element<Message> {
row![
button("◀").on_press_maybe((pagina_atual > 0).then_some(msg_anterior)),
text(format!("Página {} / {}", pagina_atual + 1, total_paginas)),
button("▶").on_press_maybe((pagina_atual + 1 < total_paginas).then_some(msg_proximo)),
]
.spacing(8)
.into()
}
```
---
### 4.11 Layout right-to-left (botões alinhados à direita)
**egui atual:** `ui.with_layout(egui::Layout::right_to_left(...))` — inverte a ordem visual dos botões.
**iced equivalente:**
```rust
row![
text(layout.nome()),
Space::with_width(Length::Fill), // empurra botões para a direita
button("📂 Carregar").on_press(...),
button("📤 Exportar JSON").on_press(...),
button("🗑 Excluir").on_press(...),
]
.spacing(8)
.align_y(Alignment::Center)
```
Ordem no código = ordem visual. Sem inversão.
---
### 4.12 Diálogos de arquivo (`rfd`)
**egui atual:** `rfd::FileDialog::new().pick_file()` chamado diretamente dentro da render function — bloqueia a thread da UI.
**iced equivalente:** Chamar via `Command::perform` para não bloquear:
```rust
Command::perform(
async { rfd::AsyncFileDialog::new().pick_file().await.map(|h| h.path().to_path_buf()) },
|resultado| match resultado {
Some(caminho) => Message::ArquivoSelecionado(caminho),
None => Message::Noop,
}
)
```
> `rfd::AsyncFileDialog` é o equivalente async. Não bloqueia.
---
## 5. Tela por Tela — O Que Reescrever
### 5.1 `import.rs` → `screens/import.rs`
| Elemento atual | Equivalente iced | Observação |
|---|---|---|
| `rfd::FileDialog::pick_file()` síncrono | `rfd::AsyncFileDialog` via `Command::perform` | Não bloqueia |
| `egui::ComboBox` para layouts | `iced::widget::pick_list` | API mais simples |
| Botão "Selecionar arquivo" | Botão + **drop_zone** | Adiciona drag-and-drop |
| Navegação via `app.estado = ...` | `Message::IrParaConfiguracaoColunas` | Via update() |
**Novidade:** A zona de drop de arquivos vai nesta tela, substituindo/complementando o botão de seleção.
---
### 5.2 `import.rs::renderizar_selecao_aba` → `screens/selecionar_aba.rs`
| Elemento atual | Equivalente iced |
|---|---|
| `for aba in &abas { ui.selectable_label(...) }` | `column` de `radio` ou `button` por aba |
| Pré-visualização da aba | Componente `tabela_preview` reutilizável |
---
### 5.3 `configuracao_colunas.rs` → `screens/configuracao_colunas.rs`
| Elemento atual | Equivalente iced |
|---|---|
| `egui::DragValue` para índices numéricos | `text_input` com validação numérica ou `number_input` (iced_aw) |
| `egui::Checkbox` para campos opcionais | `iced::widget::checkbox` |
| `egui::ComboBox` para delimitador/encoding/aba | `iced::widget::pick_list` |
| `ui.add_enabled_ui(...)` para desabilitar botão | `button(...).on_press_maybe(valido.then_some(msg))` |
| `ui.colored_label(RED, erro)` | `text(erro).style(Color::from_rgb(0.8, 0.0, 0.0))` |
| Atualização de preview ao mudar delimitador | `Message::DelimitadorAlterado``update()` regenera preview |
---
### 5.4 `resultado.rs` → `screens/resultado.rs`
| Elemento atual | Equivalente iced |
|---|---|
| `resultado.clone()` a cada frame | `&self.estado` lido em `view(&self)` sem clone |
| Paginação duplicada para faltantes e duplicatas | Componente `controles_paginacao` reutilizável |
| `ui.ctx().copy_text(...)` | `iced::clipboard::write(texto)` via Command |
| `egui::ScrollArea::vertical()` | `iced::widget::scrollable` |
| Exportar PDF via `rfd` síncrono | `rfd::AsyncFileDialog` via `Command::perform` |
**Melhoria possível aqui:** barra de busca/filtro por número, que a tela de resultado atual não tem. Adicionar `Message::FiltroBusca(String)` e filtrar `faltantes_por_serie` na view.
---
### 5.5 `layouts.rs` → `screens/layouts.rs`
| Elemento atual | Equivalente iced |
|---|---|
| `ui.with_layout(right_to_left)` para alinhar botões | `row![ Space::Fill, botão1, botão2 ]` |
| `rfd` síncrono para importar/exportar JSON | `rfd::AsyncFileDialog` via `Command::perform` |
| `egui::ScrollArea::vertical()` | `iced::widget::scrollable` |
---
### 5.6 `app.rs` — Tela "Analisando"
**egui atual:**
```rust
ui.centered_and_justified(|ui| {
ui.label(egui::RichText::new("⏳ Analisando... aguarde.").size(22.0).strong());
});
```
**iced equivalente:**
```rust
container(
column![
text("⏳ Analisando... aguarde.").size(22),
// Opcional: iced::widget::progress_bar indeterminado
]
.align_x(Alignment::Center)
)
.center(Length::Fill)
.into()
```
---
## 6. Funcionalidades Novas Habilitadas pela Migração
Estas funcionalidades não existem no egui mas ficam acessíveis com iced:
| Funcionalidade | Como implementar em iced |
|---|---|
| **Drag-and-drop de arquivos** | `iced::widget::drop_zone` + `Message::ArquivoSelecionado` |
| **Dark/Light mode** | `fn theme(&self) -> iced::Theme` + `Message::TemaAlterado` |
| **Busca/filtro nos resultados** | `text_input` + filtrar `faltantes_por_serie` na view |
| **Histórico de análises** | Nova tela + tabela + persistência via SQLite (infra não muda) |
| **Gráficos** | `plotters` com backend iced ou `iced_charts` |
| **Modal real bloqueante** | `Stack` + overlay escuro captura eventos — impede cliques atrás |
| **Diálogos não-bloqueantes** | `rfd::AsyncFileDialog` — não trava a UI durante seleção |
---
## 7. O Que Não Muda
Tudo fora de `src/ui/` permanece intacto:
- `domain/entities/`: `Nota`, `ChaveSerie`, `Layout`, `ResultadoAnalise`, `ResultadoPreAnalise`
- `domain/services/`: `detector_sequencia`, `detector_duplicidade`, `parser_monetario`
- `domain/errors.rs`: `ErroLayout`, `ResumoAvisos`, todos os erros tipados
- `application/usecases/`: `importar_arquivo`, `executar_analise`, `exportar_pdf`, `layouts`
- `infrastructure/`: `csv_reader`, `xlsx_reader`, `pdf_generator`, `sqlite/`
---
## 8. Ordem Recomendada de Implementação
A migração deve ser feita de forma que o projeto compile e funcione a cada etapa, nunca quebrando por mais de uma sessão de trabalho.
### Fase 1 — Esqueleto (sem funcionalidade)
1. Substituir `eframe`/`egui` por `iced` no `Cargo.toml`
2. Reescrever `main.rs` com `iced::application()`
3. Criar `ui/message.rs` com o enum `Message` completo
4. Criar `ui/app.rs` com struct `App`, impl `Application` mínimo (view retorna `text("em construção")`)
5. Confirmar que compila
### Fase 2 — Tela de Importação
1. Implementar `screens/import.rs` com seleção de arquivo (botão + drop_zone)
2. Implementar `Message::ArquivoSelecionado` em `update()`
3. Dropdown de layouts funcionando
4. Navegação básica entre estados
### Fase 3 — Configuração de Colunas
1. Implementar `screens/configuracao_colunas.rs`
2. Formulário CSV completo com validação
3. Formulário XLSX completo
4. Componente `tabela_preview`
5. Background task de importação via `Command::perform`
### Fase 4 — Resultado
1. Implementar `screens/resultado.rs`
2. Componente `controles_paginacao` reutilizável
3. Copiar para clipboard
4. Exportar PDF via `rfd::AsyncFileDialog`
### Fase 5 — Layouts e Modal
1. Implementar `screens/layouts.rs`
2. Implementar componente `modal.rs`
3. Todas as ações de modal (confirmação, input de texto, erro, aviso)
### Fase 6 — Funcionalidades Novas
1. Dark/Light mode
2. Busca/filtro nos resultados
3. Histórico de análises (requer nova tela + schema SQLite v4)
---
## 9. Riscos e Mitigações
| Risco | Probabilidade | Mitigação |
|---|---|---|
| API do iced quebra em versão minor | Alta (projeto pré-1.0) | Fixar versão exata no Cargo.toml: `iced = "=0.13.x"` |
| `iced_aw` (componentes extras) desatualizado | Média | Implementar `number_input` e `modal` internamente se necessário |
| Performance com 100.000 registros na view | Baixa | iced tem renderização incremental; usar `lazy` widget para listas longas |
| `plotters` + iced requer configuração extra | Média | Avaliar na Fase 6; pode ser substituído por barras simples manuais |
| Quebra de compatibilidade na branch `master` | Zero | Branch `change-ui` é isolada; `master` não é afetada |
---
## 10. Referências
- Repositório iced: https://github.com/iced-rs/iced
- Exemplos oficiais: https://github.com/iced-rs/iced/tree/master/examples
- `iced_aw` (componentes extras): https://github.com/iced-rs/iced_aw
- `rfd` async: https://docs.rs/rfd/latest/rfd/struct.AsyncFileDialog.html
- `plotters` com iced: https://github.com/plotters-rs/plotters-iced