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

20 KiB
Raw Blame History

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"] }

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:

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 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:

// 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_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:

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.rsscreens/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_abascreens/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.rsscreens/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::DelimitadorAlteradoupdate() regenera preview

5.4 resultado.rsscreens/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.rsscreens/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, 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