8.3 KiB
Implementação — Reordenar Faixas Externas (botões ↑↓)
Data: 01/03/2026
Status: ✅ Implementado
Prioridade: Baixa — melhoria de usabilidade
Arquivos afetados:
src/domain/entities/project.rssrc/ui/components/added_track_list.rssrc/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 | ~5–6h |
| 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
BatchItemtem seu próprioProjectindependente; a mesma lógica se aplica.
Checklist de implementação
Project::move_track()adicionado e testado (4 testes novos)AddedTrackEventcom variantesMoveUpeMoveDown- Grid de 5 colunas com botões ↑↓ desabilitados nas bordas
- Handler em
app.rstratando os dois novos eventos cargo test— 72 testes passando, 0 falhascargo checksem warnings (3 warnings pré-existentes não relacionados)