Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 79 additions & 120 deletions src/usecases/ui/panels/library.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,40 @@

use super::super::{LibraryDrag, UIActions, UIData};

/// Render a stream/URL library entry as a single row.
///
/// The remove button is reserved on the right (via a right-to-left layout) and
/// the URL label truncates to the remaining width, so a long URL can never force
/// the library panel wider than its resized/default size. The full text is shown
/// on hover. Returns `true` when the remove button was clicked.
fn stream_row(
ui: &mut egui::Ui,
item_id: egui::Id,
payload: LibraryDrag,
status_color: egui::Color32,
text: String,
) -> bool {
let mut remove = false;
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
remove = ui
.small_button("✕")
.on_hover_text("Remove from library")
.clicked();
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.dnd_drag_source(item_id, payload, |ui| {
ui.label(egui::RichText::new("●").color(status_color));
ui.add(
egui::Label::new(egui::RichText::new(text.clone()).size(12.0)).truncate(),
)
.on_hover_text(text);
});
});
});
});
remove
}

pub(super) fn render_library_panel(ui: &mut egui::Ui, data: &UIData, actions: &mut UIActions) {
ui.horizontal(|ui| {
ui.heading("📚 Library");
Expand Down Expand Up @@ -284,31 +318,15 @@ pub(super) fn render_library_panel(ui: &mut egui::Ui, data: &UIData, actions: &m
};
let mode = entry.mode;
let url = entry.url.clone();
ui.horizontal(|ui| {
ui.dnd_drag_source(
item_id,
LibraryDrag::Srt(url.clone(), mode),
|ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("●")
.color(status_color),
);
ui.label(
egui::RichText::new(format!("📺 {}", url))
.size(12.0),
);
});
},
);
if ui
.small_button("✕")
.on_hover_text("Remove from library")
.clicked()
{
actions.srt_library_remove = Some(url.clone());
}
});
if stream_row(
ui,
item_id,
LibraryDrag::Srt(url.clone(), mode),
status_color,
format!("📺 {}", url),
) {
actions.srt_library_remove = Some(url.clone());
}
ui.label(
egui::RichText::new(format!(" Mode: {}", entry.mode))
.size(10.0)
Expand Down Expand Up @@ -375,31 +393,15 @@ pub(super) fn render_library_panel(ui: &mut egui::Ui, data: &UIData, actions: &m
egui::Color32::from_rgb(180, 180, 180)
};
let url = entry.url.clone();
ui.horizontal(|ui| {
ui.dnd_drag_source(
item_id,
LibraryDrag::Hls(url.clone()),
|ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("●")
.color(status_color),
);
ui.label(
egui::RichText::new(format!("📡 {}", url))
.size(12.0),
);
});
},
);
if ui
.small_button("✕")
.on_hover_text("Remove from library")
.clicked()
{
actions.hls_library_remove = Some(url.clone());
}
});
if stream_row(
ui,
item_id,
LibraryDrag::Hls(url.clone()),
status_color,
format!("📡 {}", url),
) {
actions.hls_library_remove = Some(url.clone());
}
if ui.ctx().is_being_dragged(item_id) {
ui.ctx().memory_mut(|mem| {
mem.data.insert_temp(
Expand Down Expand Up @@ -461,31 +463,15 @@ pub(super) fn render_library_panel(ui: &mut egui::Ui, data: &UIData, actions: &m
egui::Color32::from_rgb(180, 180, 180)
};
let url = entry.url.clone();
ui.horizontal(|ui| {
ui.dnd_drag_source(
item_id,
LibraryDrag::Dash(url.clone()),
|ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("●")
.color(status_color),
);
ui.label(
egui::RichText::new(format!("📡 {}", url))
.size(12.0),
);
});
},
);
if ui
.small_button("✕")
.on_hover_text("Remove from library")
.clicked()
{
actions.dash_library_remove = Some(url.clone());
}
});
if stream_row(
ui,
item_id,
LibraryDrag::Dash(url.clone()),
status_color,
format!("📡 {}", url),
) {
actions.dash_library_remove = Some(url.clone());
}
if ui.ctx().is_being_dragged(item_id) {
ui.ctx().memory_mut(|mem| {
mem.data.insert_temp(
Expand Down Expand Up @@ -610,34 +596,15 @@ pub(super) fn render_library_panel(ui: &mut egui::Ui, data: &UIData, actions: &m
};
let url = entry.url.clone();
let mode = entry.mode;
ui.horizontal(|ui| {
ui.dnd_drag_source(
item_id,
LibraryDrag::Rtmp(url.clone(), mode),
|ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("●")
.color(status_color),
);
ui.label(
egui::RichText::new(format!(
"📺 {} ({})",
url, mode
))
.size(12.0),
);
});
},
);
if ui
.small_button("✕")
.on_hover_text("Remove from library")
.clicked()
{
actions.rtmp_library_remove = Some(url.clone());
}
});
if stream_row(
ui,
item_id,
LibraryDrag::Rtmp(url.clone(), mode),
status_color,
format!("📺 {} ({})", url, mode),
) {
actions.rtmp_library_remove = Some(url.clone());
}
if ui.ctx().is_being_dragged(item_id) {
ui.ctx().memory_mut(|mem| {
mem.data.insert_temp(
Expand Down Expand Up @@ -700,23 +667,15 @@ pub(super) fn render_library_panel(ui: &mut egui::Ui, data: &UIData, actions: &m
egui::Color32::from_rgb(180, 180, 180)
};
let url = entry.url.clone();
ui.horizontal(|ui| {
ui.dnd_drag_source(item_id, LibraryDrag::Html(url.clone()), |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("●").color(status_color));
ui.label(
egui::RichText::new(format!("🌐 {}", url)).size(12.0),
);
});
});
if ui
.small_button("✕")
.on_hover_text("Remove from library")
.clicked()
{
actions.html_library_remove = Some(url.clone());
}
});
if stream_row(
ui,
item_id,
LibraryDrag::Html(url.clone()),
status_color,
format!("🌐 {}", url),
) {
actions.html_library_remove = Some(url.clone());
}
if ui.ctx().is_being_dragged(item_id) {
ui.ctx().memory_mut(|mem| {
mem.data
Expand Down
84 changes: 84 additions & 0 deletions tests/ui_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,90 @@ fn drag(harness: &mut Harness<'static, AccActions>, start: egui::Pos2, end: egui
harness.run();
}

// ── Library URL rows never inflate the panel width ──────────────────
//
// A resizable `egui::Panel` persists its content rect every frame, so any row
// wider than the panel's resized/default size overrides the user's drag and
// snaps the panel back to fit the content (and reveals the mixer texture beneath
// the UI during a resize). Long stream URLs used to do exactly this. The fix is
// the button-first `right_to_left` + truncating-label layout in
// `stream_row`; this test guards that layout against regressions.
const LONG_URL: &str =
"https://very-long-cdn-hostname.example.com/live/premium/channel/12345/master-playlist-with-a-really-long-query.m3u8?token=abcdefghijklmnopqrstuvwxyz0123456789";

const PANEL_DEFAULT_WIDTH: f32 = 220.0;

fn probe_panel_width<F>(add: F) -> f32
where
F: Fn(&mut egui::Ui) + 'static,
{
let id = egui::Id::new("probe_panel");
let mut h = egui_kittest::Harness::builder()
.with_size(egui::vec2(1280.0, 720.0))
.build_ui(move |ui| {
egui::Panel::left(id)
.min_size(180.0)
.default_size(PANEL_DEFAULT_WIDTH)
.resizable(true)
.show_inside(ui, |ui| add(ui));
});
// Run several frames to catch runaway growth (content-driven inflation).
for _ in 0..5 {
h.run();
}
h.ctx
.data_mut(|d| d.get_persisted::<egui::PanelState>(id))
.map(|s| s.rect.width())
.expect("panel state should exist")
}

/// Mirrors the production `stream_row` layout: the remove button is reserved on
/// the right and the URL label truncates into the remaining width.
fn url_row(ui: &mut egui::Ui, url: &str) {
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let _ = ui.small_button("✕");
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.dnd_drag_source(egui::Id::new("probe_dnd"), 0u32, |ui| {
ui.label(egui::RichText::new("●"));
ui.add(
egui::Label::new(egui::RichText::new(format!("📡 {}", url)).size(12.0))
.truncate(),
)
.on_hover_text(url);
});
});
});
});
}

#[test]
fn naive_url_row_inflates_panel() {
// Baseline: an untruncated label in a plain `horizontal` layout forces the
// panel far past its default width, reproducing the reported bug.
let naive = probe_panel_width(|ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(format!("📡 {}", LONG_URL)).size(12.0));
let _ = ui.small_button("✕");
});
});
assert!(
naive > PANEL_DEFAULT_WIDTH * 2.0,
"expected the naive layout to inflate the panel, got {naive}"
);
}

#[test]
fn truncating_url_row_keeps_panel_width() {
// The `stream_row` layout keeps the panel pinned to its default width even
// with a very long URL, so user resizes are never overridden.
let fixed = probe_panel_width(|ui| url_row(ui, LONG_URL));
assert!(
(fixed - PANEL_DEFAULT_WIDTH).abs() < 1.0,
"expected the truncating layout to hold the panel at {PANEL_DEFAULT_WIDTH}, got {fixed}"
);
}

// ── Add Channel ─────────────────────────────────────────────────────

#[test]
Expand Down