Files
simple-multimidia-track-aud…/docs/impl-reorder-external-tracks.md

8.3 KiB
Raw Permalink Blame History

Implementação — Reordenar Faixas Externas (botões ↑↓)

Data: 01/03/2026
Status: Implementado
Prioridade: Baixa — melhoria de usabilidade
Arquivos afetados:

  • src/domain/entities/project.rs
  • src/ui/components/added_track_list.rs
  • src/ui/app.rs

Objetivo

Permitir que o usuário reordene as faixas externas adicionadas (áudio e legenda) usando botões ↑↓ na lista. A ordem do Vec<Track> em project.tracks determina diretamente o índice de stream no container MKV final — já que ambos os command builders (FfmpegCommandBuilder e MkvmergeCommandBuilder) iteram esse Vec posicionalmente a cada build().


Por que botões ↑↓ e não drag-and-drop

Critério Botões ↑↓ Drag-and-drop (egui)
Esforço de implementação ~1h ~56h
Risco de regressão Nulo — mudanças aditivas Baixo, mas requer estado extra no componente
Clareza de UX Explícita, sem ambiguidade Mais fluido, porém menos óbvio em grids
Testabilidade Método de domínio testável isoladamente Lógica de UI difícil de testar

Conclusão: botões ↑↓ entregam 100% do valor com ~15% do esforço.


Análise da arquitetura atual

A ordem das faixas externas já é a fonte de verdade do índice no output:

// FfmpegCommandBuilder::build() — sem cache de índice
let mut ext_idx = external_input_start;
for track in &project.tracks {   // ← itera em ordem
    args.push("-map".to_string());
    // ...
    ext_idx += 1;
}
// MkvmergeCommandBuilder::build() — idem
for track in &project.tracks {   // ← itera em ordem
    // cada arquivo externo vira um input separado
}

TrackId é opaco (TrackId(u32)) — não representa posição, apenas identidade. Uma troca de posição no Vec não invalida nenhum TrackId existente.


Passo 1 — Método move_track em Project

Arquivo: src/domain/entities/project.rs

Adicionar logo após remove_track:

/// Move uma faixa externa para cima (`delta = -1`) ou para baixo (`delta = 1`).
/// Retorna `true` se a faixa foi encontrada e o movimento era possível.
pub fn move_track(&mut self, id: TrackId, delta: i8) -> bool {
    let Some(pos) = self.tracks.iter().position(|t| t.id() == id) else {
        return false;
    };
    let new_pos = pos as i64 + delta as i64;
    if new_pos < 0 || new_pos >= self.tracks.len() as i64 {
        return false;
    }
    self.tracks.swap(pos, new_pos as usize);
    true
}

Passo 2 — Novos eventos em AddedTrackEvent

Arquivo: src/ui/components/added_track_list.rs

pub enum AddedTrackEvent {
    RemoveRequested(TrackId),
    MoveUp(TrackId),   // ← novo
    MoveDown(TrackId), // ← novo
}

Passo 3 — Botões ↑↓ na grid

Arquivo: src/ui/components/added_track_list.rs

Alterar a assinatura do método para receber o índice atual e o total, e adicionar os botões na última coluna.

A grid passa de 4 para 5 colunas (num_columns(5)). O cabeçalho ganha uma coluna "" extra. Cada linha recebe os botões ↑ e ↓, desabilitados na primeira e última posição respectivamente:

pub fn ui(&mut self, ui: &mut egui::Ui, tracks: &[Track]) -> Vec<AddedTrackEvent> {
    let mut events = Vec::new();
    let total = tracks.len();

    ui.group(|ui| {
        ui.heading("Faixas adicionadas");

        if tracks.is_empty() {
            ui.label("Nenhuma faixa adicionada.");
            return;
        }

        egui::Grid::new("added_tracks_grid")
            .num_columns(5)          // ← era 4
            .max_col_width(200.0)
            .striped(true)
            .show(ui, |ui| {
                ui.strong("Tipo");
                ui.strong("Arquivo");
                ui.strong("Idioma");
                ui.strong("");  // coluna de ordem
                ui.strong("");  // coluna de remover
                ui.end_row();

                for (idx, track) in tracks.iter().enumerate() {
                    // ... células existentes (tipo, arquivo, idioma) ...

                    // ── Botões de ordenação ──
                    ui.horizontal(|ui| {
                        ui.add_enabled_ui(idx > 0, |ui| {
                            if ui.small_button("↑").clicked() {
                                events.push(AddedTrackEvent::MoveUp(track.id()));
                            }
                        });
                        ui.add_enabled_ui(idx + 1 < total, |ui| {
                            if ui.small_button("↓").clicked() {
                                events.push(AddedTrackEvent::MoveDown(track.id()));
                            }
                        });
                    });

                    if ui.small_button("🗑 Remover").clicked() {
                        events.push(AddedTrackEvent::RemoveRequested(track.id()));
                    }

                    ui.end_row();
                }
            });
    });

    events
}

Passo 4 — Tratar eventos em app.rs

Arquivo: src/ui/app.rs

O trecho que já trata RemoveRequested precisa ser expandido:

let track_events = self.added_track_list.ui(ui, &project.tracks);
for event in track_events {
    use crate::ui::components::added_track_list::AddedTrackEvent;
    match event {
        AddedTrackEvent::RemoveRequested(id) => {
            let _ = RemoveTrack::execute(project, id);
        }
        AddedTrackEvent::MoveUp(id) => {
            project.move_track(id, -1);
        }
        AddedTrackEvent::MoveDown(id) => {
            project.move_track(id, 1);
        }
    }
}

Passo 5 — Testes unitários para move_track

Arquivo: src/domain/entities/project.rs — bloco #[cfg(test)] existente.

#[test]
fn move_track_para_cima() {
    let mut p = make_project("i.mkv", "o.mkv").unwrap();
    let t1 = audio_track_with_drift(1, 1.0);
    let t2 = audio_track_with_drift(2, 1.0);
    let id1 = t1.id();
    let id2 = t2.id();
    p.tracks.push(t1);
    p.tracks.push(t2);

    assert!(p.move_track(id2, -1));
    assert_eq!(p.tracks[0].id(), id2);
    assert_eq!(p.tracks[1].id(), id1);
}

#[test]
fn move_track_para_baixo() {
    let mut p = make_project("i.mkv", "o.mkv").unwrap();
    let t1 = audio_track_with_drift(1, 1.0);
    let t2 = audio_track_with_drift(2, 1.0);
    let id1 = t1.id();
    let id2 = t2.id();
    p.tracks.push(t1);
    p.tracks.push(t2);

    assert!(p.move_track(id1, 1));
    assert_eq!(p.tracks[0].id(), id2);
    assert_eq!(p.tracks[1].id(), id1);
}

#[test]
fn move_track_limite_superior_ignorado() {
    let mut p = make_project("i.mkv", "o.mkv").unwrap();
    let t1 = audio_track_with_drift(1, 1.0);
    let id1 = t1.id();
    p.tracks.push(t1);

    assert!(!p.move_track(id1, -1)); // já é o primeiro — não move
}

#[test]
fn move_track_limite_inferior_ignorado() {
    let mut p = make_project("i.mkv", "o.mkv").unwrap();
    let t1 = audio_track_with_drift(1, 1.0);
    let id1 = t1.id();
    p.tracks.push(t1);

    assert!(!p.move_track(id1, 1)); // já é o último — não move
}

Escopo e limitações

  • Somente faixas externas (project.tracks) são reordenáveis. Faixas existentes (project.existing_tracks, lidas via ffprobe) permanecem na ordem detectada — essa restrição deve ser comunicada visualmente (ex: tooltip ou nota abaixo da lista de faixas adicionadas).
  • A ordenação é persistida automaticamente via RF-14 (sessão salva em JSON inclui o Vec na nova ordem).
  • Sem impacto no modo Lote: cada BatchItem tem seu próprio Project independente; a mesma lógica se aplica.

Checklist de implementação

  • Project::move_track() adicionado e testado (4 testes novos)
  • AddedTrackEvent com variantes MoveUp e MoveDown
  • Grid de 5 colunas com botões ↑↓ desabilitados nas bordas
  • Handler em app.rs tratando os dois novos eventos
  • cargo test — 72 testes passando, 0 falhas
  • cargo check sem warnings (3 warnings pré-existentes não relacionados)