Fix video speedup and audio desync with monotonic timestamps, flush dropdown menu layout, clean hotkey unbinding, and new minimal icon set

This commit is contained in:
katR 2026-09-05 18:47:28 +05:30
parent cb70b53ca0
commit 5ecd9a9910
5 changed files with 247 additions and 175 deletions

View file

@ -408,14 +408,33 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
};
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<dyn std::error::Error + Send + Sync>> {
}
});
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<Frame> = None;
let mut has_new_frame = false;
@ -592,10 +613,28 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
}
},
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 {

View file

@ -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 {

View file

@ -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<i32, (*mut libc::c_void, usize)>,
@ -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<crate::ring::Packet>) -> Result<(), String> {
unsafe fn send_staged_frame(&mut self, packets: &mut Vec<crate::ring::Packet>, 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<Vec<crate::ring::Packet>, String> {
pub fn encode_frame(&mut self, frame: &Frame, pts: i64) -> Result<Vec<crate::ring::Packet>, 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<Vec<crate::ring::Packet>, String> {
pub fn encode_cached_frame(&mut self, pts: i64) -> Result<Vec<crate::ring::Packet>, 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<Vec<crate::ring::Packet>, String> {
pub fn encode_frame(&mut self, frame: &Frame, pts: i64) -> Result<Vec<crate::ring::Packet>, 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<Vec<crate::ring::Packet>, String> {
pub fn encode_cached_frame(&mut self, pts: i64) -> Result<Vec<crate::ring::Packet>, 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);

View file

@ -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);
}
}

View file

@ -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();