Create a MIGRATION_ICED.md

This commit is contained in:
FelipeCN
2026-03-04 09:43:43 -03:00
parent 12b8501ffb
commit 2d1d29ce3d
+675
View File
@@ -0,0 +1,675 @@
# 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