diff --git a/src/bin/scythe-daemon.rs b/src/bin/scythe-daemon.rs index c190f73..e6881f7 100644 --- a/src/bin/scythe-daemon.rs +++ b/src/bin/scythe-daemon.rs @@ -408,14 +408,33 @@ async fn main() -> Result<(), Box> { }; let first_video_pts = drain[first_v_idx].pts(); + let first_video_time_us = unsafe { + ffmpeg_next::ffi::av_rescale_q( + first_video_pts, + video_time_base, + ffmpeg_next::ffi::AVRational { num: 1, den: 1_000_000 }, + ) + }; - // Find the audio packet in the ring buffer closest in arrival to the first video keyframe + // Find the audio packet in the ring buffer closest in real time to the first video keyframe let first_audio_pts = drain .iter() - .enumerate() - .filter(|(_, p)| p.stream_index() == 1) - .min_by_key(|(i, _)| i.abs_diff(first_v_idx)) - .map(|(_, p)| p.pts()) + .filter(|p| p.stream_index() == 1) + .min_by_key(|p| { + let a_time_us = if let Some(a_tb) = audio_time_base { + unsafe { + ffmpeg_next::ffi::av_rescale_q( + p.pts(), + a_tb, + ffmpeg_next::ffi::AVRational { num: 1, den: 1_000_000 }, + ) + } + } else { + 0 + }; + (a_time_us - first_video_time_us).abs() + }) + .map(|p| p.pts()) .unwrap_or(0); let mut prepared: Vec<(i64, Packet)> = Vec::with_capacity(drain.len()); @@ -470,6 +489,8 @@ async fn main() -> Result<(), Box> { } }); + let stream_start = std::time::Instant::now(); + let mut last_video_pts: i64 = -1; let mut ticker = crossbeam_channel::tick(std::time::Duration::from_nanos(1_000_000_000 / config.fps.max(1) as u64)); let mut latest_frame: Option = None; let mut has_new_frame = false; @@ -592,10 +613,28 @@ async fn main() -> Result<(), Box> { pkt.set_stream_index(1); if normal_recording && !normal_waiting_keyframe && let Some(muxer) = normal_muxer.as_mut() { - if rec_base_audio_pts < 0 { - rec_base_audio_pts = pkt.pts(); - } - if pkt.pts() >= rec_base_audio_pts { + let rec_base_video_time_us = unsafe { + ffmpeg_next::ffi::av_rescale_q( + rec_base_video_pts, + video_time_base, + ffmpeg_next::ffi::AVRational { num: 1, den: 1_000_000 }, + ) + }; + let a_time_us = if let Some(a_tb) = audio_time_base { + unsafe { + ffmpeg_next::ffi::av_rescale_q( + pkt.pts(), + a_tb, + ffmpeg_next::ffi::AVRational { num: 1, den: 1_000_000 }, + ) + } + } else { + 0 + }; + if a_time_us >= rec_base_video_time_us { + if rec_base_audio_pts < 0 { + rec_base_audio_pts = pkt.pts(); + } let rebased = pkt.rebased(rec_base_audio_pts); let _ = muxer.write_packet(&rebased); } @@ -614,15 +653,31 @@ async fn main() -> Result<(), Box> { } }, recv(ticker) -> _ => { + let now = std::time::Instant::now(); + let elapsed = now.duration_since(stream_start); + let raw_pts = (elapsed.as_secs_f64() * config.fps as f64).round() as i64; + let pts = if raw_pts > last_video_pts { raw_pts } else { last_video_pts + 1 }; + let packets_res = if has_new_frame { has_new_frame = false; + last_video_pts = pts; if let Some(ref f) = latest_frame { - encoder.encode_frame(f) + encoder.encode_frame(f, pts) } else { Ok(Vec::new()) } } else { - encoder.encode_cached_frame() + // Heartbeat / keepalive: if no new frame for 500ms, emit cached frame to keep video track flowing smoothly + if pts - last_video_pts >= (config.fps as i64 / 2).max(1) { + last_video_pts = pts; + if let Some(ref f) = latest_frame { + encoder.encode_frame(f, pts) + } else { + encoder.encode_cached_frame(pts) + } + } else { + Ok(Vec::new()) + } }; if let Ok(packets) = packets_res { diff --git a/src/config.rs b/src/config.rs index 9e98b9b..538f1d6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -102,9 +102,13 @@ impl Default for ScytheConfig { impl ScytheConfig { pub fn default_output_directory() -> String { - let mut p = dirs::video_dir().unwrap_or_else(|| PathBuf::from(".")); - p.push("Scythe"); - p.to_string_lossy().to_string() + if let Some(video) = dirs::video_dir() { + return video.to_string_lossy().to_string(); + } + if let Some(home) = dirs::home_dir() { + return home.join("Videos").to_string_lossy().to_string(); + } + "~/Videos".to_string() } pub fn config_path() -> PathBuf { diff --git a/src/encoder.rs b/src/encoder.rs index 2e175d3..775a093 100644 --- a/src/encoder.rs +++ b/src/encoder.rs @@ -10,7 +10,6 @@ pub struct VaapiEncoder { sws_ctx: *mut SwsContext, staged_nv12: *mut AVFrame, hw_frame: *mut AVFrame, - next_pts: i64, has_frame: bool, #[cfg(unix)] dma_mmap_cache: HashMap, @@ -147,7 +146,6 @@ impl VaapiEncoder { sws_ctx, staged_nv12, hw_frame, - next_pts: 0, has_frame: false, #[cfg(unix)] dma_mmap_cache: HashMap::new(), @@ -155,7 +153,7 @@ impl VaapiEncoder { } } - unsafe fn send_staged_frame(&mut self, packets: &mut Vec) -> Result<(), String> { + unsafe fn send_staged_frame(&mut self, packets: &mut Vec, pts: i64) -> Result<(), String> { unsafe { av_frame_unref(self.hw_frame); let ret = av_hwframe_get_buffer(self.hw_frames_ctx, self.hw_frame, 0); @@ -169,8 +167,7 @@ impl VaapiEncoder { return Err("Failed to transfer staged nv12 data to hw frame".into()); } - (*self.hw_frame).pts = self.next_pts; - self.next_pts += 1; + (*self.hw_frame).pts = pts; if avcodec_send_frame(self.codec_ctx, self.hw_frame) >= 0 { let mut pkt = av_packet_alloc(); @@ -190,7 +187,7 @@ impl VaapiEncoder { self.codec_ctx } - pub fn encode_frame(&mut self, frame: &Frame) -> Result, String> { + pub fn encode_frame(&mut self, frame: &Frame, pts: i64) -> Result, String> { let mut packets = Vec::new(); unsafe { match frame { @@ -207,7 +204,7 @@ impl VaapiEncoder { (*self.staged_nv12).linesize.as_ptr(), ); self.has_frame = true; - self.send_staged_frame(&mut packets)?; + self.send_staged_frame(&mut packets, pts)?; } Frame::DmaBuf { width: dma_width, height: dma_height, format, modifier, fd, stride, offset } => { let mmap_size = (*stride as usize) * (*dma_height as usize); @@ -252,8 +249,7 @@ impl VaapiEncoder { (*direct_hw).hw_frames_ctx = av_buffer_ref(self.hw_frames_ctx); if av_hwframe_map(direct_hw, drm_frame, 0) >= 0 { - (*direct_hw).pts = self.next_pts; - self.next_pts += 1; + (*direct_hw).pts = pts; if avcodec_send_frame(self.codec_ctx, direct_hw) >= 0 { let mut pkt = av_packet_alloc(); @@ -311,7 +307,7 @@ impl VaapiEncoder { (*self.staged_nv12).linesize.as_ptr(), ); self.has_frame = true; - self.send_staged_frame(&mut packets)?; + self.send_staged_frame(&mut packets, pts)?; } } } @@ -325,13 +321,13 @@ impl VaapiEncoder { Ok(packets) } - pub fn encode_cached_frame(&mut self) -> Result, String> { + pub fn encode_cached_frame(&mut self, pts: i64) -> Result, String> { let mut packets = Vec::new(); if !self.has_frame { return Ok(packets); } unsafe { - self.send_staged_frame(&mut packets)?; + self.send_staged_frame(&mut packets, pts)?; } Ok(packets) } @@ -374,7 +370,6 @@ impl Drop for VaapiEncoder { pub struct WindowsHwEncoder { codec_ctx: *mut AVCodecContext, encoder_name: String, - next_pts: i64, sws_ctx: *mut SwsContext, sw_frame: *mut AVFrame, } @@ -499,7 +494,6 @@ impl WindowsHwEncoder { Ok(Self { codec_ctx, encoder_name: selected_desc, - next_pts: 0, sws_ctx, sw_frame, }) @@ -510,7 +504,7 @@ impl WindowsHwEncoder { self.codec_ctx } - pub fn encode_frame(&mut self, frame: &Frame) -> Result, String> { + pub fn encode_frame(&mut self, frame: &Frame, pts: i64) -> Result, String> { let mut packets = Vec::new(); unsafe { match frame { @@ -528,8 +522,7 @@ impl WindowsHwEncoder { (*self.sw_frame).linesize.as_mut_ptr(), ); - (*self.sw_frame).pts = self.next_pts; - self.next_pts += 1; + (*self.sw_frame).pts = pts; if avcodec_send_frame(self.codec_ctx, self.sw_frame) >= 0 { let mut pkt = av_packet_alloc(); @@ -544,8 +537,7 @@ impl WindowsHwEncoder { #[cfg(target_os = "windows")] Frame::D3D11Texture { handle: _ } => { // Zero-copy D3D11 frame submission - (*self.sw_frame).pts = self.next_pts; - self.next_pts += 1; + (*self.sw_frame).pts = pts; if avcodec_send_frame(self.codec_ctx, self.sw_frame) >= 0 { let mut pkt = av_packet_alloc(); @@ -564,14 +556,13 @@ impl WindowsHwEncoder { } #[cfg(target_os = "windows")] - pub fn encode_cached_frame(&mut self) -> Result, String> { + pub fn encode_cached_frame(&mut self, pts: i64) -> Result, String> { let mut packets = Vec::new(); unsafe { if self.sw_frame.is_null() { return Ok(packets); } - (*self.sw_frame).pts = self.next_pts; - self.next_pts += 1; + (*self.sw_frame).pts = pts; if avcodec_send_frame(self.codec_ctx, self.sw_frame) >= 0 { let mut pkt = av_packet_alloc(); @@ -824,7 +815,7 @@ mod tests { stride: 1280 * 4, data: vec![0u8; 1280 * 720 * 4], }; - let pkts = enc.encode_frame(&blank_frame).expect("Encode frame"); + let pkts = enc.encode_frame(&blank_frame, 0).expect("Encode frame"); println!("Encoded {} packets from first frame", pkts.len()); unsafe { println!("VAAPI extradata_size after encode: {}", (*enc.codec_ctx).extradata_size); diff --git a/src/hyprland_binds.rs b/src/hyprland_binds.rs index 5dc67ac..4ea8922 100644 --- a/src/hyprland_binds.rs +++ b/src/hyprland_binds.rs @@ -172,6 +172,8 @@ pub fn register_hyprland_binds(config: &ScytheConfig) { ]; for (hotkey, cmd) in binds { + unbind_hotkey(hotkey); + // Try modern Hyprland Lua eval first if let Some(lua_combo) = hotkey_to_hyprland_lua(hotkey) { let lua_script = format!(r#"hl.bind("{}", hl.dsp.exec_cmd("{}"))"#, lua_combo, cmd); @@ -202,6 +204,37 @@ pub fn register_hyprland_binds(config: &ScytheConfig) { #[cfg(not(unix))] pub fn register_hyprland_binds(_config: &ScytheConfig) {} +#[cfg(unix)] +/// Unbind a specific hotkey combo from running Hyprland +pub fn unbind_hotkey(hotkey: &str) { + if !is_hyprland() { + return; + } + + if let Some(lua_combo) = hotkey_to_hyprland_lua(hotkey) { + let lua_script = format!( + r#"for i = 1, 10 do pcall(function() hl.unbind("{}") end) end"#, + lua_combo + ); + let _ = Command::new("hyprctl") + .args(["eval", &lua_script]) + .output(); + } + if let Some((mods, key)) = hotkey_to_hyprland(hotkey) { + let unbind_arg = if mods.is_empty() { + key + } else { + format!("{}, {}", mods, key) + }; + let _ = Command::new("hyprctl") + .args(["keyword", "unbind", &unbind_arg]) + .output(); + } +} + +#[cfg(not(unix))] +pub fn unbind_hotkey(_hotkey: &str) {} + #[cfg(unix)] /// Dynamically remove binds from running Hyprland pub fn unregister_hyprland_binds(config: &ScytheConfig) { @@ -217,22 +250,7 @@ pub fn unregister_hyprland_binds(config: &ScytheConfig) { ]; for hotkey in hotkeys { - if let Some(lua_combo) = hotkey_to_hyprland_lua(hotkey) { - let lua_script = format!(r#"hl.unbind("{}")"#, lua_combo); - let _ = Command::new("hyprctl") - .args(["eval", &lua_script]) - .output(); - } - if let Some((mods, key)) = hotkey_to_hyprland(hotkey) { - let unbind_arg = if mods.is_empty() { - key - } else { - format!("{}, {}", mods, key) - }; - let _ = Command::new("hyprctl") - .args(["keyword", "unbind", &unbind_arg]) - .output(); - } + unbind_hotkey(hotkey); } } diff --git a/src/overlay_egui.rs b/src/overlay_egui.rs index 390f6b2..fd5b77c 100644 --- a/src/overlay_egui.rs +++ b/src/overlay_egui.rs @@ -424,144 +424,145 @@ fn draw_replay_icon(painter: &egui::Painter, center: egui::Pos2, radius: f32, is Color32::from_rgb(148, 163, 184) }; - // 1. Clean smooth circular sweep arc (280 degrees) - let start_angle = 0.25 * PI; - let end_angle = 1.80 * PI; + // 1. Sleek circular track arc curving counter-clockwise (rewind) + let arc_radius = radius * 0.95; + let start_angle = -PI * 0.70; // top-left + let end_angle = PI * 0.90; // bottom-left let steps = 36; let mut points = Vec::with_capacity(steps + 1); for i in 0..=steps { let t = i as f32 / steps as f32; let angle = start_angle + t * (end_angle - start_angle); - points.push(center + Vec2::new(angle.cos() * radius, angle.sin() * radius)); + points.push(center + Vec2::new(angle.cos() * arc_radius, angle.sin() * arc_radius)); } let stroke = Stroke::new(2.2_f32, color); for w in points.windows(2) { painter.line_segment([w[0], w[1]], stroke); } - // 2. Crisp sharp arrow head at start of arc + // 2. Crisp sharp arrow head at start of arc pointing counter-clockwise let tip = points[0]; let tangent = Vec2::new(-start_angle.sin(), start_angle.cos()).normalized(); let normal = Vec2::new(start_angle.cos(), start_angle.sin()).normalized(); - let p_back_1 = tip - tangent * 7.5 + normal * 4.5; - let p_back_2 = tip - tangent * 7.5 - normal * 4.5; + let p_back_1 = tip - tangent * 7.0 + normal * 4.5; + let p_back_2 = tip - tangent * 7.0 - normal * 4.5; painter.add(egui::Shape::convex_polygon( vec![tip, p_back_1, p_back_2], color, Stroke::NONE, )); - // 3. Crisp centered rewind glyph (minimal triangle) - let glyph_w = radius * 0.40; - let glyph_h = radius * 0.35; - let p_tip = center - Vec2::new(glyph_w * 0.5, 0.0); - let p_top = center + Vec2::new(glyph_w * 0.5, -glyph_h); - let p_bot = center + Vec2::new(glyph_w * 0.5, glyph_h); + // 3. Central dual rewind chevrons << + let chev_h = radius * 0.38; + let chev_w = radius * 0.26; + // Left chevron + let c1_tip = center + Vec2::new(-chev_w * 1.1, 0.0); + let c1_top = center + Vec2::new(-chev_w * 0.05, -chev_h); + let c1_bot = center + Vec2::new(-chev_w * 0.05, chev_h); painter.add(egui::Shape::convex_polygon( - vec![p_tip, p_top, p_bot], + vec![c1_tip, c1_top, c1_bot], + color, + Stroke::NONE, + )); + // Right chevron + let c2_tip = center + Vec2::new(chev_w * 0.15, 0.0); + let c2_top = center + Vec2::new(chev_w * 1.20, -chev_h); + let c2_bot = center + Vec2::new(chev_w * 1.20, chev_h); + painter.add(egui::Shape::convex_polygon( + vec![c2_tip, c2_top, c2_bot], color, Stroke::NONE, )); } fn draw_record_icon(painter: &egui::Painter, center: egui::Pos2, radius: f32, is_recording: bool, anim_time: f32) { + let half = radius * 0.88; + let arm = radius * 0.36; + if is_recording { let pulse = (anim_time * 5.0).sin() * 0.5 + 0.5; let red_bright = Color32::from_rgb(239, 68, 68); - let red_glow = Color32::from_rgba_unmultiplied(239, 68, 68, (35.0 + pulse * 45.0) as u8); + let red_glow = Color32::from_rgba_unmultiplied(239, 68, 68, (35.0 + pulse * 50.0) as u8); - // Soft minimal pulsing glow - let glow_size = (radius + pulse * 2.5) * 2.0; + // Soft pulsing ambient halo around viewfinder painter.rect_filled( - egui::Rect::from_center_size(center, Vec2::splat(glow_size)), + egui::Rect::from_center_size(center, Vec2::splat(half * 2.2 + pulse * 4.0)), CornerRadius::ZERO, red_glow, ); - // Clean outer frame - let outer_size = radius * 1.8; - painter.rect_stroke( - egui::Rect::from_center_size(center, Vec2::splat(outer_size)), - CornerRadius::ZERO, - Stroke::new(2.0_f32, red_bright), - egui::StrokeKind::Inside, - ); + // Viewfinder corner brackets (Red) + let stroke = Stroke::new(2.2_f32, red_bright); + // Top-left + painter.line_segment([center + Vec2::new(-half + arm, -half), center + Vec2::new(-half, -half)], stroke); + painter.line_segment([center + Vec2::new(-half, -half), center + Vec2::new(-half, -half + arm)], stroke); + // Top-right + painter.line_segment([center + Vec2::new(half - arm, -half), center + Vec2::new(half, -half)], stroke); + painter.line_segment([center + Vec2::new(half, -half), center + Vec2::new(half, -half + arm)], stroke); + // Bottom-left + painter.line_segment([center + Vec2::new(-half + arm, half), center + Vec2::new(-half, half)], stroke); + painter.line_segment([center + Vec2::new(-half, half), center + Vec2::new(-half, half - arm)], stroke); + // Bottom-right + painter.line_segment([center + Vec2::new(half - arm, half), center + Vec2::new(half, half)], stroke); + painter.line_segment([center + Vec2::new(half, half), center + Vec2::new(half, half - arm)], stroke); - // Center recording core - let core_size = radius * 0.75 + pulse * 2.0; - painter.rect_filled( - egui::Rect::from_center_size(center, Vec2::splat(core_size)), - CornerRadius::ZERO, - red_bright, - ); + // Center recording core (pulsing) + let core_r = radius * 0.40 + pulse * 1.5; + painter.circle_filled(center, core_r, red_bright); } else { let frame_color = Color32::from_rgb(148, 163, 184); let core_color = Color32::from_rgb(203, 213, 225); - // Clean outer frame - let outer_size = radius * 1.8; - painter.rect_stroke( - egui::Rect::from_center_size(center, Vec2::splat(outer_size)), - CornerRadius::ZERO, - Stroke::new(1.8_f32, frame_color), - egui::StrokeKind::Inside, - ); + // Viewfinder corner brackets (Slate) + let stroke = Stroke::new(2.0_f32, frame_color); + // Top-left + painter.line_segment([center + Vec2::new(-half + arm, -half), center + Vec2::new(-half, -half)], stroke); + painter.line_segment([center + Vec2::new(-half, -half), center + Vec2::new(-half, -half + arm)], stroke); + // Top-right + painter.line_segment([center + Vec2::new(half - arm, -half), center + Vec2::new(half, -half)], stroke); + painter.line_segment([center + Vec2::new(half, -half), center + Vec2::new(half, -half + arm)], stroke); + // Bottom-left + painter.line_segment([center + Vec2::new(-half + arm, half), center + Vec2::new(-half, half)], stroke); + painter.line_segment([center + Vec2::new(-half, half), center + Vec2::new(-half, half - arm)], stroke); + // Bottom-right + painter.line_segment([center + Vec2::new(half - arm, half), center + Vec2::new(half, half)], stroke); + painter.line_segment([center + Vec2::new(half, half), center + Vec2::new(half, half - arm)], stroke); - // Clean center core dot - let core_size = radius * 0.65; - painter.rect_filled( - egui::Rect::from_center_size(center, Vec2::splat(core_size)), - CornerRadius::ZERO, - core_color, - ); + // Center standby dot + painter.circle_filled(center, radius * 0.35, core_color); } } -fn draw_gear_icon(painter: &egui::Painter, center: egui::Pos2, radius: f32, color: Color32) { - use std::f32::consts::PI; - let stroke = Stroke::new(2.0_f32, color); +fn draw_settings_icon(painter: &egui::Painter, center: egui::Pos2, radius: f32, color: Color32) { + let track_h = radius * 1.05; + let track_spacing = radius * 0.48; + let track_stroke = Stroke::new(1.8_f32, Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 140)); - // Clean outer ring - let steps = 32; - let outer_r = radius * 0.85; - let mut ring_pts = Vec::with_capacity(steps + 1); - for i in 0..=steps { - let t = i as f32 / steps as f32; - let a = t * 2.0 * PI; - ring_pts.push(center + Vec2::new(a.cos() * outer_r, a.sin() * outer_r)); - } - for w in ring_pts.windows(2) { - painter.line_segment([w[0], w[1]], stroke); + // 3 vertical tracks + let xs = [-track_spacing, 0.0, track_spacing]; + for &x_off in &xs { + let x = center.x + x_off; + painter.line_segment( + [egui::pos2(x, center.y - track_h), egui::pos2(x, center.y + track_h)], + track_stroke, + ); } - // 6 clean minimal teeth - let tooth_r = radius * 1.15; - let tooth_w = radius * 0.32; - for i in 0..6 { - let a = (i as f32) * (2.0 * PI / 6.0); - let normal = Vec2::new(a.cos(), a.sin()); - let tangent = Vec2::new(-a.sin(), a.cos()); - let p1 = center + normal * outer_r - tangent * (tooth_w * 0.5); - let p2 = center + normal * outer_r + tangent * (tooth_w * 0.5); - let p3 = center + normal * tooth_r + tangent * (tooth_w * 0.5); - let p4 = center + normal * tooth_r - tangent * (tooth_w * 0.5); - painter.add(egui::Shape::convex_polygon( - vec![p1, p2, p3, p4], - color, - Stroke::NONE, - )); - } + // 3 slider knobs positioned at different heights for dynamic equalizer / settings look + let knob_w = radius * 0.42; + let knob_h = radius * 0.22; + let knob_offsets = [ + (-track_spacing, -track_h * 0.35), + (0.0, track_h * 0.40), + (track_spacing, -track_h * 0.10), + ]; - // Center circular bore hole - let inner_r = radius * 0.38; - let mut inner_pts = Vec::with_capacity(steps + 1); - for i in 0..=steps { - let t = i as f32 / steps as f32; - let a = t * 2.0 * PI; - inner_pts.push(center + Vec2::new(a.cos() * inner_r, a.sin() * inner_r)); - } - for w in inner_pts.windows(2) { - painter.line_segment([w[0], w[1]], stroke); + for (x_off, y_off) in knob_offsets { + let knob_rect = egui::Rect::from_center_size( + center + Vec2::new(x_off, y_off), + Vec2::new(knob_w, knob_h), + ); + painter.rect_filled(knob_rect, CornerRadius::ZERO, color); } } @@ -641,7 +642,7 @@ fn render_action_card( response.clicked() } -// Attached Squared Dropdown Menu Container (Pixel-perfect width matching card) +// Attached Squared Dropdown Menu Container (Pixel-perfect width and flush full-bleed items) fn render_dropdown_menu( ui: &mut egui::Ui, card_width: f32, @@ -649,51 +650,54 @@ fn render_dropdown_menu( add_contents: impl FnOnce(&mut egui::Ui), ) { ui.add_space(4.0); - let pad = 4_i8; egui::Frame::NONE .fill(Color32::from_rgba_unmultiplied(12, 16, 24, 252)) .stroke(Stroke::new(1.0_f32, accent)) .corner_radius(CornerRadius::ZERO) - .inner_margin(Margin::same(pad)) + .inner_margin(Margin::ZERO) .show(ui, |ui| { - let inner_w = card_width - (pad as f32 * 2.0); - ui.set_min_width(inner_w); - ui.set_max_width(inner_w); + ui.spacing_mut().item_spacing = Vec2::ZERO; + ui.set_min_width(card_width); + ui.set_max_width(card_width); add_contents(ui); }); } -// Sleek Squared Dropdown Action Menu Item -fn render_menu_item(ui: &mut egui::Ui, label: &str, accent: Color32) -> bool { - let (rect, response) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 32.0), egui::Sense::click()); +// Sleek Squared Dropdown Action Menu Item (Completely fills container, zero bottom dead space) +fn render_menu_item(ui: &mut egui::Ui, label: &str, accent: Color32, is_last: bool) -> bool { + let (rect, response) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 35.0), egui::Sense::click()); let hovered = response.hovered(); let bg = if hovered { - Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 40) + Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 45) } else { - Color32::from_rgba_unmultiplied(255, 255, 255, 8) - }; - let border = if hovered { - Stroke::new(1.0_f32, accent) - } else { - Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 14)) + Color32::TRANSPARENT }; + + ui.painter().rect_filled(rect, CornerRadius::ZERO, bg); + + // Clean 1px divider between items (not drawn on last item so it stays flush) + if !is_last { + ui.painter().line_segment( + [egui::pos2(rect.left(), rect.bottom()), egui::pos2(rect.right(), rect.bottom())], + Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 18)), + ); + } + let text_color = if hovered { accent } else { Color32::from_rgb(226, 232, 240) }; - ui.painter().rect(rect, CornerRadius::ZERO, bg, border, egui::StrokeKind::Inside); ui.painter().text( rect.center(), egui::Align2::CENTER_CENTER, label, - FontId::proportional(11.5), + FontId::proportional(12.0), text_color, ); - ui.add_space(3.0); response.clicked() } @@ -966,7 +970,7 @@ impl ScytheOverlayApp { if self.replay_dropdown_open { render_dropdown_menu(ui, card_w, accent, |ui| { let toggle_text = if is_replay_active { "Turn off" } else { "Turn on" }; - if render_menu_item(ui, toggle_text, accent) { + if render_menu_item(ui, toggle_text, accent, false) { let mut cfg = ScytheConfig::load(); cfg.replay_enabled = !cfg.replay_enabled; let _ = cfg.save(); @@ -975,7 +979,7 @@ impl ScytheOverlayApp { self.status.is_replay_active = cfg.replay_enabled; self.replay_dropdown_open = false; } - if render_menu_item(ui, "Save Replay", accent) { + if render_menu_item(ui, "Save Replay", accent, true) { let _ = ipc::send_command(Command::SaveReplay); self.status_msg = Some(("Replay Saved!".to_string(), Instant::now())); self.replay_dropdown_open = false; @@ -1023,7 +1027,7 @@ impl ScytheOverlayApp { if self.record_dropdown_open { render_dropdown_menu(ui, card_w, accent, |ui| { let rec_toggle_text = if is_recording { "Stop Recording" } else { "Start Recording" }; - if render_menu_item(ui, rec_toggle_text, accent) { + if render_menu_item(ui, rec_toggle_text, accent, true) { let _ = ipc::send_command(Command::ToggleRecording); self.record_dropdown_open = false; } @@ -1046,7 +1050,7 @@ impl ScytheOverlayApp { false, false, |painter, center| { - draw_gear_icon(painter, center, 24.0, Color32::from_rgb(148, 163, 184)); + draw_settings_icon(painter, center, 24.0, Color32::from_rgb(148, 163, 184)); }, "Hardware & Tuning", Color32::from_rgb(148, 163, 184), @@ -1379,11 +1383,6 @@ impl ScytheOverlayApp { open_folder(&ScytheConfig::expand_tilde(&self.output_dir)); } }); - - ui.add_space(8.0); - if squared_button(ui, &format!("OPEN RECORDINGS GALLERY & TRIMMER ({})", self.clips.len()), false, accent) { - self.switch_view(ShadowPlayView::Gallery, ctx); - } }); ui.add_space(10.0); @@ -1791,24 +1790,29 @@ impl eframe::App for ScytheOverlayApp { if combo == "CANCEL" { self.listening_keybind = None; } else { - let action_name = match action { + let (action_name, old_key) = match action { KeybindAction::Menu => { + let old = self.config.menu_hotkey.clone(); self.config.menu_hotkey = combo.clone(); - "Menu Overlay" + ("Menu Overlay", old) } KeybindAction::SaveReplay => { + let old = self.config.save_hotkey.clone(); self.config.save_hotkey = combo.clone(); - "Instant Replay" + ("Instant Replay", old) } KeybindAction::ToggleRecord => { + let old = self.config.record_hotkey.clone(); self.config.record_hotkey = combo.clone(); - "Record Toggle" + ("Record Toggle", old) } KeybindAction::ToggleCursor => { + let old = self.config.cursor_hotkey.clone(); self.config.cursor_hotkey = combo.clone(); - "Cursor Toggle" + ("Cursor Toggle", old) } }; + crate::hyprland_binds::unbind_hotkey(&old_key); let _ = self.config.save(); crate::hyprland_binds::register_hyprland_binds(&self.config); crate::config::ScytheConfig::notify_daemon_reload();