use eframe::egui;
use log::info;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use crate::frame::{Frame, FrameStatus};
use crate::player::Player;
use crate::scrub::Scrubber;
use crate::shaders::Shaders;
use crate::timeslider::{time_slider, SequenceRange, TimeSliderConfig};
use crate::viewport::{ViewportRenderer, ViewportState};
use crate::utils::media;
fn create_image_dialog(title: &str) -> rfd::FileDialog {
rfd::FileDialog::new()
.add_filter("All Supported Files", media::ALL_EXTS)
.set_title(title)
}
pub fn help_text() -> &'static str {
"Drag'n'drop a file here or double-click to open\n\n\
Hotkeys:\n\
F1 - Toggle this help\n\
F2 - Toggle playlist\n\
F3 - Preferences\n\
F7 - Video Encoding\n\
ESC - Exit Fullscreen / Quit\n\n\
Z - Toggle Fullscreen\n\
Ctrl+R - Reset Settings\n\n\
' / ` - Toggle Loop\n\
B - Set Play Range Start\n\
N - Set Play Range End\n\
Ctrl+B - Reset Play Range\n\n\
Playback:\n\
Space - Play/Pause\n\
J / , / ← - Backward\n\
K / ↓ - Stop/Dec FPS\n\
L / . / → - Forward\n\
Ctrl+← / ↑ - Go Start\n\
Ctrl+→ - Go End\n\n\
View:\n\
A / 1 - 100% Zoom\n\
Home / H - Fit to View\n\
F - Fit to View\n\
Mouse:\n\
Mouse Wheel - Zoom\n\
Middle Drag - Pan\n\
Left Click - Scrub"
}
pub struct PlaylistActions {
pub load_sequence: Option<PathBuf>,
pub clear_all: bool,
pub save_playlist: Option<PathBuf>,
pub load_playlist: Option<PathBuf>,
}
pub fn render_playlist(
ctx: &egui::Context,
player: &mut Player,
) -> PlaylistActions {
let mut actions = PlaylistActions {
load_sequence: None,
clear_all: false,
save_playlist: None,
load_playlist: None,
};
egui::SidePanel::right("playlist")
.default_width(250.0)
.min_width(20.0)
.resizable(true)
.show(ctx, |ui| {
egui::ScrollArea::both()
.auto_shrink([false; 2])
.show(ui, |ui| {
ui.heading("Sequences");
ui.horizontal(|ui| {
if ui.button("Add").clicked() {
if let Some(paths) = create_image_dialog("Add Files").pick_files() {
if !paths.is_empty() {
info!("Add button: loading {}", paths[0].display());
actions.load_sequence = Some(paths[0].clone());
}
}
}
if ui.button("Clear").clicked() {
actions.clear_all = true;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let has_selection = player.selected_seq_idx.is_some();
ui.add_enabled_ui(has_selection, |ui| {
if ui.button("↓ Down").clicked() {
if let Some(idx) = player.selected_seq_idx {
let new_idx = (idx + 1).min(player.cache.sequences().len().saturating_sub(1));
player.cache.move_seq(idx, 1);
player.selected_seq_idx = Some(new_idx);
}
}
if ui.button("↑ Up").clicked() {
if let Some(idx) = player.selected_seq_idx {
let new_idx = idx.saturating_sub(1);
player.cache.move_seq(idx, -1);
player.selected_seq_idx = Some(new_idx);
}
}
});
});
});
ui.horizontal(|ui| {
if ui.button("Save").clicked() {
if let Some(path) = rfd::FileDialog::new()
.add_filter("JSON Playlist", &["json"])
.set_title("Save Playlist")
.save_file()
{
actions.save_playlist = Some(path);
}
}
if ui.button("Load").clicked() {
if let Some(path) = rfd::FileDialog::new()
.add_filter("JSON Playlist", &["json"])
.set_title("Load Playlist")
.pick_file()
{
actions.load_playlist = Some(path);
}
}
});
ui.separator();
egui::ScrollArea::vertical().auto_shrink([false; 2]).show(ui, |ui| {
let mut to_remove: Option<usize> = None;
let mut to_select: Option<usize> = None;
for (idx, seq) in player.cache.sequences().iter().enumerate() {
ui.horizontal(|ui| {
let is_selected = player.selected_seq_idx == Some(idx);
if ui.selectable_label(is_selected, seq.pattern()).clicked() {
to_select = Some(idx);
}
ui.label(format!("{}f", seq.len()));
if ui.small_button("X").clicked() {
to_remove = Some(idx);
}
});
}
if let Some(idx) = to_select {
player.cache.jump_to_seq(idx);
player.selected_seq_idx = Some(idx);
}
if let Some(idx) = to_remove {
player.cache.remove_seq(idx);
}
});
});
});
actions
}
pub fn render_controls(
ctx: &egui::Context,
player: &mut Player,
shader_manager: &mut Shaders,
cached_seq_ranges: &mut Vec<SequenceRange>,
last_seq_version: &mut usize,
) -> bool {
let old_shader = shader_manager.current_shader.clone();
egui::TopBottomPanel::bottom("controls").show(ctx, |ui| {
ui.add_space(8.0);
ui.vertical_centered(|ui| {
ui.horizontal(|ui| {
if ui.button("⏮ Start").clicked() {
player.to_start();
}
let play_text = if player.is_playing { "⏸ Pause" } else { "▶ Play" };
if ui.button(play_text).clicked() {
player.toggle_play_pause();
}
if ui.button("End ⏭").clicked() {
player.to_end();
}
});
});
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.checkbox(&mut player.loop_enabled, "Loop");
ui.separator();
ui.label("FPS:");
egui::ComboBox::from_id_salt("fps_combo")
.selected_text(format!("{:.0}", player.fps))
.show_ui(ui, |ui| {
for &fps_value in &[1.0, 2.0, 4.0, 8.0, 12.0, 24.0, 30.0, 60.0, 120.0, 240.0] {
ui.selectable_value(&mut player.fps, fps_value, format!("{:.0}", fps_value));
}
});
ui.add(
egui::DragValue::new(&mut player.fps)
.speed(0.1)
.range(0.00000001..=1000.0)
);
ui.separator();
ui.label("Shader:");
egui::ComboBox::from_id_salt("shader_combo")
.selected_text(format!("{}", shader_manager.current_shader))
.show_ui(ui, |ui| {
for shader_name in shader_manager.get_shader_names() {
ui.selectable_value(
&mut shader_manager.current_shader,
shader_name.clone(),
shader_name
);
}
});
ui.separator();
ui.label("Frame:");
ui.label(format!("{}", player.current_frame()));
});
ui.add_space(4.0);
if *last_seq_version != player.cache.sequences_version() {
*cached_seq_ranges = build_sequence_ranges(player);
*last_seq_version = player.cache.sequences_version();
}
let config = TimeSliderConfig::default();
if let Some(new_frame) = time_slider(
ui,
player.current_frame(),
player.total_frames(),
cached_seq_ranges,
&config,
&player.cache,
) {
player.set_frame(new_frame);
}
ui.add_space(8.0);
});
old_shader != shader_manager.current_shader
}
fn build_sequence_ranges(player: &Player) -> Vec<SequenceRange> {
let sequences = player.cache.sequences();
let mut ranges = Vec::new();
let mut global_offset = 0;
for seq in sequences {
let frame_count = seq.len();
ranges.push(SequenceRange {
start_frame: global_offset,
end_frame: global_offset + frame_count.saturating_sub(1),
pattern: seq.pattern().to_string(),
});
global_offset += frame_count;
}
ranges
}
pub struct ViewportActions {
pub load_sequence: Option<PathBuf>,
}
pub fn render_viewport(
ctx: &egui::Context,
frame: Option<&Frame>,
error_msg: Option<&String>,
player: &mut Player,
viewport_state: &mut ViewportState,
viewport_renderer: &Arc<Mutex<ViewportRenderer>>,
scrubber: &mut Option<Scrubber>,
show_help: bool,
is_fullscreen: bool,
texture_needs_upload: bool,
) -> (ViewportActions, f32) {
let mut actions = ViewportActions {
load_sequence: None,
};
let mut render_time_ms = 0.0;
let central = if is_fullscreen {
egui::CentralPanel::default().frame(
egui::Frame::new().fill(egui::Color32::BLACK)
)
} else {
egui::CentralPanel::default()
};
central.show(ctx, |ui| {
let panel_rect = ui.max_rect();
let response = ui.interact(panel_rect, ui.id().with("viewport_interaction"), egui::Sense::click_and_drag());
let double_clicked = response.double_clicked() ||
(ctx.input(|i| i.pointer.button_double_clicked(egui::PointerButton::Primary))
&& response.hovered());
if double_clicked {
info!("Double-click detected, opening file dialog");
if let Some(path) = create_image_dialog("Select Image File").pick_file() {
info!("File selected: {}", path.display());
actions.load_sequence = Some(path);
}
}
if let Some(error) = error_msg {
ui.centered_and_justified(|ui| {
ui.colored_label(egui::Color32::RED, error);
});
} else if let Some(img) = frame {
let w = img.width();
let h = img.height();
let frame_state = img.status();
let available_size = panel_rect.size();
if viewport_state.viewport_size != available_size {
viewport_state.set_viewport_size(available_size);
}
let image_size = egui::vec2(w as f32, h as f32);
if viewport_state.image_size != image_size {
viewport_state.set_image_size(image_size);
}
handle_viewport_input(ctx, ui, panel_rect, viewport_state);
if !double_clicked {
if let Some(scrubber) = scrubber {
use crate::scrub::Scrubber;
if response.clicked_by(egui::PointerButton::Primary) || response.dragged_by(egui::PointerButton::Primary) {
if let Some(original_mouse_pos) = response.interact_pointer_pos() {
if !scrubber.is_active() {
let current_bounds = viewport_state.get_image_screen_bounds();
let current_size = viewport_state.image_size;
let normalized = Scrubber::mouse_to_normalized(original_mouse_pos.x, current_bounds);
scrubber.start_scrubbing(current_bounds, current_size, normalized);
scrubber.set_last_mouse_x(original_mouse_pos.x);
if player.is_playing {
player.toggle_play_pause();
}
}
let image_bounds = scrubber.frozen_bounds()
.unwrap_or_else(|| viewport_state.get_image_screen_bounds());
if scrubber.mouse_moved(original_mouse_pos.x) {
let normalized = Scrubber::mouse_to_normalized(original_mouse_pos.x, image_bounds);
scrubber.set_normalized_position(normalized);
scrubber.set_last_mouse_x(original_mouse_pos.x);
let is_clamped = normalized < 0.0 || normalized > 1.0;
scrubber.set_clamped(is_clamped);
let frame_idx = Scrubber::normalized_to_frame(normalized, player.total_frames());
player.set_frame(frame_idx);
player.cache.signal_preload(); scrubber.set_current_frame(frame_idx);
scrubber.set_visual_x(original_mouse_pos.x);
} else {
let saved_normalized = scrubber.normalized_position().unwrap_or(0.5);
let is_clamped = saved_normalized < 0.0 || saved_normalized > 1.0;
scrubber.set_clamped(is_clamped);
let frame_idx = Scrubber::normalized_to_frame(saved_normalized, player.total_frames());
player.set_frame(frame_idx);
player.cache.signal_preload(); scrubber.set_current_frame(frame_idx);
let visual_x = Scrubber::normalized_to_pixel(saved_normalized, image_bounds);
scrubber.set_visual_x(visual_x);
}
}
}
if scrubber.is_active()
&& !response.dragged()
&& !ctx.input(|i| i.pointer.button_down(egui::PointerButton::Primary))
{
scrubber.stop_scrubbing();
}
if scrubber.is_active() {
ctx.set_cursor_icon(egui::CursorIcon::None);
}
}
}
let render_start = std::time::Instant::now();
let renderer = viewport_renderer.clone();
let state = viewport_state.clone();
let mut needs_upload = texture_needs_upload;
{
let r = renderer.lock().unwrap();
if r.needs_texture_update(w, h) { needs_upload = true; }
}
let upload_payload: Option<(std::sync::Arc<crate::frame::PixelBuffer>, crate::frame::PixelFormat)> = if needs_upload {
Some((img.pixel_buffer(), img.pixel_format()))
} else {
None
};
ui.painter().add(egui::PaintCallback {
rect: panel_rect,
callback: std::sync::Arc::new(egui_glow::CallbackFn::new(move |_info, painter: &egui_glow::Painter| {
let gl = painter.gl();
let mut r = renderer.lock().unwrap();
if let Some((pixel_buffer, pixel_format)) = &upload_payload {
r.upload_texture(gl, w, h, pixel_buffer, *pixel_format);
}
r.render(gl, &state);
})),
});
render_time_ms = render_start.elapsed().as_secs_f32() * 1000.0;
if let Ok(r) = viewport_renderer.lock() {
if let Some(err) = r.shader_error() {
let overlay = egui::Color32::from_rgba_unmultiplied(50, 0, 0, 180);
let text = egui::Color32::from_rgb(255, 220, 220);
let margin = egui::vec2(10.0, 10.0);
let galley = ui.painter().layout_no_wrap(
format!("Shader Error: {}", err),
egui::FontId::monospace(12.0),
text,
);
let rect = egui::Rect::from_min_size(panel_rect.left_top() + margin, galley.size());
ui.painter().rect_filled(rect.expand(6.0), 4.0, overlay);
ui.painter().text(
rect.min + egui::vec2(6.0, 2.0),
egui::Align2::LEFT_TOP,
format!("Shader Error: {}", err),
egui::FontId::monospace(12.0),
text,
);
}
}
match frame_state {
FrameStatus::Loading => {
ui.painter().text(
panel_rect.center(),
egui::Align2::CENTER_CENTER,
format!("Loading frame {}...", player.current_frame()),
egui::FontId::proportional(24.0),
egui::Color32::from_rgba_premultiplied(255, 255, 255, 200),
);
}
FrameStatus::Error => {
ui.painter().text(
panel_rect.center(),
egui::Align2::CENTER_CENTER,
format!("Failed to load frame {}", player.current_frame()),
egui::FontId::proportional(24.0),
egui::Color32::from_rgb(255, 100, 100),
);
}
FrameStatus::Loaded | FrameStatus::Header | FrameStatus::Placeholder => {
}
}
if let Some(scrubber) = scrubber {
scrubber.draw(ui, panel_rect);
}
if show_help && !is_fullscreen {
render_help_overlay(ui, panel_rect);
}
} else if show_help && !is_fullscreen {
let panel_rect = ui.max_rect();
render_help_overlay(ui, panel_rect);
}
});
(actions, render_time_ms)
}
fn handle_viewport_input(
ctx: &egui::Context,
_ui: &egui::Ui,
rect: egui::Rect,
viewport_state: &mut ViewportState,
) {
let scroll_delta = ctx.input(|i| i.raw_scroll_delta);
if scroll_delta.y.abs() > 0.1 {
let cursor_pos = ctx.input(|i| i.pointer.hover_pos());
if let Some(cursor_pos) = cursor_pos {
if rect.contains(cursor_pos) {
let relative_pos = cursor_pos - rect.left_top();
viewport_state.handle_zoom(scroll_delta.y, relative_pos);
ctx.request_repaint();
}
}
}
let pointer = ctx.input(|i| i.pointer.clone());
if pointer.button_down(egui::PointerButton::Middle) {
let delta = pointer.delta();
if delta.length() > 0.1 {
viewport_state.handle_pan(delta);
ctx.request_repaint();
}
}
}
fn render_help_overlay(ui: &egui::Ui, panel_rect: egui::Rect) {
ui.painter().text(
panel_rect.left_top() + egui::vec2(10.0, 10.0),
egui::Align2::LEFT_TOP,
help_text(),
egui::FontId::proportional(13.0),
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 128),
);
}