20 KiB
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
eframe = "0.31"
egui = "0.31"
image = { version = "0.25", default-features = false, features = ["ico"] }
Adicionar
iced = { version = "0.13", features = ["tokio", "image"] }
tokio = { version = "1", features = ["full"] }
rfdpermanece. Continua sendo usado para diálogos de arquivo nativos.imagepode 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:
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:
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:
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.
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):
// 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:
// 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 emtokio::task::spawn_blockingimportar_xlsx→ idempre_analisar→ idemexpandir_analise→ idemexportar_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:
// 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:
// 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_zoneestá 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:
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:
pub tema: Tema,
E uma mensagem:
Message::TemaAlterado(Tema),
4.8 Breadcrumb de etapas
egui atual:
egui::TopBottomPanel::top("breadcrumb").show(ctx, |ui| { ... });
iced equivalente:
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:
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:
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:
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:
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:
ui.centered_and_justified(|ui| {
ui.label(egui::RichText::new("⏳ Analisando... aguarde.").size(22.0).strong());
});
iced equivalente:
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,ResultadoPreAnalisedomain/services/:detector_sequencia,detector_duplicidade,parser_monetariodomain/errors.rs:ErroLayout,ResumoAvisos, todos os erros tipadosapplication/usecases/:importar_arquivo,executar_analise,exportar_pdf,layoutsinfrastructure/: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)
- Substituir
eframe/eguiporicednoCargo.toml - Reescrever
main.rscomiced::application() - Criar
ui/message.rscom o enumMessagecompleto - Criar
ui/app.rscom structApp, implApplicationmínimo (view retornatext("em construção")) - Confirmar que compila
Fase 2 — Tela de Importação
- Implementar
screens/import.rscom seleção de arquivo (botão + drop_zone) - Implementar
Message::ArquivoSelecionadoemupdate() - Dropdown de layouts funcionando
- Navegação básica entre estados
Fase 3 — Configuração de Colunas
- Implementar
screens/configuracao_colunas.rs - Formulário CSV completo com validação
- Formulário XLSX completo
- Componente
tabela_preview - Background task de importação via
Command::perform
Fase 4 — Resultado
- Implementar
screens/resultado.rs - Componente
controles_paginacaoreutilizável - Copiar para clipboard
- Exportar PDF via
rfd::AsyncFileDialog
Fase 5 — Layouts e Modal
- Implementar
screens/layouts.rs - Implementar componente
modal.rs - Todas as ações de modal (confirmação, input de texto, erro, aviso)
Fase 6 — Funcionalidades Novas
- Dark/Light mode
- Busca/filtro nos resultados
- 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_awrfdasync: https://docs.rs/rfd/latest/rfd/struct.AsyncFileDialog.htmlplotterscom iced: https://github.com/plotters-rs/plotters-iced