From b2d18297da1731fbc2670b955830925e070f1ecc Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Fri, 18 Sep 2026 19:48:43 +0530 Subject: [PATCH] fix(windows): resolve capture freeze, duplicate hotkey saves, overlay transparency, and settings UI alignment --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/bin/scythe-daemon.rs | 33 ++++-- src/bin/scythe-ui.rs | 5 +- src/capture/windows.rs | 154 ++++++++++++++++++---------- src/encoder.rs | 36 +++++++ src/overlay_egui.rs | 214 ++++++++++++++++++++++----------------- 7 files changed, 289 insertions(+), 157 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4843433..d82ccc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3700,7 +3700,7 @@ dependencies = [ [[package]] name = "scythe" -version = "0.2.14" +version = "0.2.15" dependencies = [ "ashpd", "cpal", diff --git a/Cargo.toml b/Cargo.toml index 6d0b8d7..e728f90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "scythe" -version = "0.2.14" +version = "0.2.15" edition = "2024" [[bin]] diff --git a/src/bin/scythe-daemon.rs b/src/bin/scythe-daemon.rs index 7d8f6ef..20f1e27 100644 --- a/src/bin/scythe-daemon.rs +++ b/src/bin/scythe-daemon.rs @@ -461,6 +461,8 @@ async fn main() -> Result<(), Box> { let mut normal_muxer: Option = None; let mut normal_recording = false; let mut normal_waiting_keyframe = false; + let mut normal_keyframe_timeout: usize = 0; + let mut last_replay_save = std::time::Instant::now() - std::time::Duration::from_secs(10); let mut rec_base_video_pts: i64 = 0; let mut rec_base_audio_pts: i64 = -1; @@ -648,12 +650,18 @@ async fn main() -> Result<(), Box> { }, Command::SaveReplay => { if config.replay_enabled { - let drain = ring.iter().cloned().collect::>(); - println!("SaveReplay triggered: {} packets in ring buffer", drain.len()); - if drain.is_empty() { - eprintln!("Warning: Replay buffer is empty (no frames encoded yet)."); + let now = std::time::Instant::now(); + if now.duration_since(last_replay_save) > std::time::Duration::from_millis(800) { + last_replay_save = now; + let drain = ring.iter().cloned().collect::>(); + println!("SaveReplay triggered: {} packets in ring buffer", drain.len()); + if drain.is_empty() { + eprintln!("Warning: Replay buffer is empty (no frames encoded yet)."); + } else { + let _ = mux_tx.send(drain); + } } else { - let _ = mux_tx.send(drain); + println!("Ignoring duplicate SaveReplay command (debounced)"); } } else { println!("SaveReplay requested but replay is disabled in daemon config."); @@ -663,11 +671,13 @@ async fn main() -> Result<(), Box> { if !normal_recording { normal_recording = true; normal_waiting_keyframe = true; + normal_keyframe_timeout = 0; + encoder.force_keyframe(); rec_state_clone.store(true, Ordering::SeqCst); rec_base_audio_pts = -1; let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); rec_start_clone.store(now, Ordering::SeqCst); - println!("StartRecording requested, waiting for keyframe..."); + println!("StartRecording requested, forcing keyframe..."); } }, Command::StopRecording => { @@ -677,6 +687,7 @@ async fn main() -> Result<(), Box> { } normal_recording = false; normal_waiting_keyframe = false; + normal_keyframe_timeout = 0; rec_base_video_pts = 0; rec_base_audio_pts = -1; rec_state_clone.store(false, Ordering::SeqCst); @@ -692,6 +703,7 @@ async fn main() -> Result<(), Box> { } normal_recording = false; normal_waiting_keyframe = false; + normal_keyframe_timeout = 0; rec_base_video_pts = 0; rec_base_audio_pts = -1; rec_state_clone.store(false, Ordering::SeqCst); @@ -701,11 +713,13 @@ async fn main() -> Result<(), Box> { } else { normal_recording = true; normal_waiting_keyframe = true; + normal_keyframe_timeout = 0; + encoder.force_keyframe(); rec_state_clone.store(true, Ordering::SeqCst); rec_base_audio_pts = -1; let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(); rec_start_clone.store(now, Ordering::SeqCst); - println!("ToggleRecording: StartRecording requested, waiting for keyframe..."); + println!("ToggleRecording: StartRecording requested, forcing keyframe..."); } }, Command::ToggleAudio => { @@ -942,8 +956,9 @@ async fn main() -> Result<(), Box> { for mut pkt in packets { pkt.set_stream_index(0); if normal_recording { - if normal_waiting_keyframe && pkt.is_keyframe() { + if normal_waiting_keyframe && (pkt.is_keyframe() || normal_keyframe_timeout >= 20) { normal_waiting_keyframe = false; + normal_keyframe_timeout = 0; rec_base_video_pts = pkt.pts(); rec_base_audio_pts = -1; let codec_ctx = codec_ctx_ptr as *mut ffmpeg_next::ffi::AVCodecContext; @@ -952,6 +967,8 @@ async fn main() -> Result<(), Box> { let audio_codec_ctx = audio_codec_ctx_ptr.map(|p| p as *mut ffmpeg_next::ffi::AVCodecContext); normal_muxer = unsafe { Muxer::new(&full_path, codec_ctx, audio_codec_ctx).ok() }; println!("Started normal recording to {}", full_path); + } else if normal_waiting_keyframe { + normal_keyframe_timeout += 1; } if !normal_waiting_keyframe diff --git a/src/bin/scythe-ui.rs b/src/bin/scythe-ui.rs index f560ce7..c631598 100644 --- a/src/bin/scythe-ui.rs +++ b/src/bin/scythe-ui.rs @@ -3,7 +3,7 @@ use scythe::ipc::{Command, send_command, query_status}; use scythe::overlay::{show_shadowplay_toast, ToastIcon}; use std::env; -use global_hotkey::{GlobalHotKeyManager, hotkey::{HotKey, Modifiers, Code}, GlobalHotKeyEvent}; +use global_hotkey::{GlobalHotKeyManager, hotkey::{HotKey, Modifiers, Code}, GlobalHotKeyEvent, HotKeyState}; fn ensure_wayland_env() { scythe::overlay::ensure_wayland_env(); @@ -572,6 +572,9 @@ fn main() -> Result<(), Box> { } while let Ok(event) = receiver.try_recv() { + if event.state != HotKeyState::Pressed { + continue; + } if Some(event.id) == id_menu { std::thread::spawn(|| { ensure_daemon_running(); diff --git a/src/capture/windows.rs b/src/capture/windows.rs index 235432c..b7d6dc5 100644 --- a/src/capture/windows.rs +++ b/src/capture/windows.rs @@ -15,7 +15,7 @@ use std::sync::Arc; pub struct WindowsCapture { device: ID3D11Device, context: ID3D11DeviceContext, - duplication: IDXGIOutputDuplication, + duplication: Option, staging_textures: [ID3D11Texture2D; 2], staging_idx: usize, has_staged_frame: bool, @@ -94,7 +94,7 @@ impl WindowsCapture { let height = (desc.DesktopCoordinates.bottom - desc.DesktopCoordinates.top).unsigned_abs(); // Initialize Desktop Duplication - let duplication = output1.DuplicateOutput(&device)?; + let duplication = output1.DuplicateOutput(&device).ok(); // Allocate double-buffered CPU-accessible staging textures for asynchronous zero-stall GPU readback let staging_desc = D3D11_TEXTURE2D_DESC { @@ -139,53 +139,81 @@ impl WindowsCapture { fn reinit_duplication(&mut self) -> Result<(), Box> { unsafe { + // Drop old duplication interface first to release COM reference + self.duplication = None; + self.has_staged_frame = false; + self.staging_idx = 0; + + // Wait a moment for DWM compositor to release the previous duplication handle + std::thread::sleep(std::time::Duration::from_millis(30)); + + // Check if device was removed/lost + if self.device.GetDeviceRemovedReason().is_err() { + println!("D3D11 device removed or reset. Recreating device..."); + if let Ok(new_cap) = WindowsCapture::new() { + self.device = new_cap.device; + self.context = new_cap.context; + self.duplication = new_cap.duplication; + self.staging_textures = new_cap.staging_textures; + self.width = new_cap.width; + self.height = new_cap.height; + self.last_stride = new_cap.last_stride; + self.buffer_pool.clear(); + return Ok(()); + } + } + let dxgi_device: IDXGIDevice = self.device.cast()?; let adapter = dxgi_device.GetAdapter()?; let mut o_idx = 0; while let Ok(output) = adapter.EnumOutputs(o_idx) { if let Ok(output1) = output.cast::() { - if let Ok(dup) = output1.DuplicateOutput(&self.device) { - self.duplication = dup; - self.has_staged_frame = false; - self.staging_idx = 0; - if let Ok(desc) = output1.GetDesc() { - let new_w = (desc.DesktopCoordinates.right - desc.DesktopCoordinates.left).unsigned_abs(); - let new_h = (desc.DesktopCoordinates.bottom - desc.DesktopCoordinates.top).unsigned_abs(); - if new_w != self.width || new_h != self.height { - self.width = new_w; - self.height = new_h; - self.last_stride = new_w * 4; - self.buffer_pool.clear(); - self.last_frame = None; - let staging_desc = D3D11_TEXTURE2D_DESC { - Width: new_w, - Height: new_h, - MipLevels: 1, - ArraySize: 1, - Format: DXGI_FORMAT_B8G8R8A8_UNORM, - SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, - Usage: D3D11_USAGE_STAGING, - BindFlags: 0, - CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32, - MiscFlags: 0, - }; - let mut tex0: Option = None; - let mut tex1: Option = None; - let ok0 = self.device.CreateTexture2D(&staging_desc, None, Some(&mut tex0)).is_ok(); - let ok1 = self.device.CreateTexture2D(&staging_desc, None, Some(&mut tex1)).is_ok(); - if ok0 && ok1 { - if let (Some(t0), Some(t1)) = (tex0, tex1) { - self.staging_textures = [t0, t1]; + match output1.DuplicateOutput(&self.device) { + Ok(dup) => { + self.duplication = Some(dup); + if let Ok(desc) = output1.GetDesc() { + let new_w = (desc.DesktopCoordinates.right - desc.DesktopCoordinates.left).unsigned_abs(); + let new_h = (desc.DesktopCoordinates.bottom - desc.DesktopCoordinates.top).unsigned_abs(); + if new_w != self.width || new_h != self.height { + self.width = new_w; + self.height = new_h; + self.last_stride = new_w * 4; + self.buffer_pool.clear(); + self.last_frame = None; + let staging_desc = D3D11_TEXTURE2D_DESC { + Width: new_w, + Height: new_h, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_B8G8R8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 }, + Usage: D3D11_USAGE_STAGING, + BindFlags: 0, + CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32, + MiscFlags: 0, + }; + let mut tex0: Option = None; + let mut tex1: Option = None; + let ok0 = self.device.CreateTexture2D(&staging_desc, None, Some(&mut tex0)).is_ok(); + let ok1 = self.device.CreateTexture2D(&staging_desc, None, Some(&mut tex1)).is_ok(); + if ok0 && ok1 { + if let (Some(t0), Some(t1)) = (tex0, tex1) { + self.staging_textures = [t0, t1]; + } } } } + println!("Successfully reinitialized Windows desktop duplication: {}x{}", self.width, self.height); + return Ok(()); + } + Err(e) => { + eprintln!("DuplicateOutput attempt on output {} failed: {:?}", o_idx, e); } - return Ok(()); } } o_idx += 1; } - Err("Failed to reinitialize desktop duplication".into()) + Err("Failed to reinitialize desktop duplication on any output".into()) } } } @@ -194,11 +222,36 @@ impl WindowsCapture { impl FrameSource for WindowsCapture { fn next_frame(&mut self) -> Result> { unsafe { + if self.duplication.is_none() { + let _ = self.reinit_duplication(); + } + + let mut dup = match self.duplication.clone() { + Some(d) => d, + None => { + if let Some(ref last) = self.last_frame { + return Ok(Frame::Raw { + width: self.width, + height: self.height, + stride: self.last_stride, + data: Arc::clone(last), + }); + } else { + return Ok(Frame::Raw { + width: self.width, + height: self.height, + stride: self.last_stride, + data: Arc::new(vec![0u8; (self.last_stride * self.height) as usize]), + }); + } + } + }; + let mut frame_info = DXGI_OUTDUPL_FRAME_INFO::default(); let mut desktop_resource: Option = None; - for _ in 0..10 { - match self.duplication.AcquireNextFrame(25, &mut frame_info, &mut desktop_resource) { + for _ in 0..4 { + match dup.AcquireNextFrame(8, &mut frame_info, &mut desktop_resource) { Ok(()) => { if let Some(resource) = desktop_resource { let texture: ID3D11Texture2D = resource.cast()?; @@ -210,7 +263,7 @@ impl FrameSource for WindowsCapture { self.context.CopyResource(&self.staging_textures[write_idx], &texture); // Immediately release the desktop frame back to the DWM compositor - let _ = self.duplication.ReleaseFrame(); + let _ = dup.ReleaseFrame(); // Use ping-pong texture: // On first frame, read write_idx directly. @@ -269,33 +322,32 @@ impl FrameSource for WindowsCapture { data: to_send, }); } - let _ = self.duplication.ReleaseFrame(); + let _ = dup.ReleaseFrame(); } Err(e) if e.code() == DXGI_ERROR_WAIT_TIMEOUT => { - // Desktop static / no new frame presented. Sleep briefly and wait. - // Do NOT allocate duplicate frames! - std::thread::sleep(std::time::Duration::from_millis(3)); - continue; + // Desktop static / no new frame presented. + break; } Err(e) if e.code() == DXGI_ERROR_ACCESS_LOST || e.code() == DXGI_ERROR_ACCESS_DENIED || e.code() == DXGI_ERROR_INVALID_CALL => { - let _ = self.duplication.ReleaseFrame(); + let _ = dup.ReleaseFrame(); + drop(dup); + self.duplication = None; let _ = self.reinit_duplication(); - std::thread::sleep(std::time::Duration::from_millis(20)); - continue; + break; } Err(e) => { - let _ = self.duplication.ReleaseFrame(); + let _ = dup.ReleaseFrame(); + drop(dup); + self.duplication = None; let _ = self.reinit_duplication(); - std::thread::sleep(std::time::Duration::from_millis(16)); return Err(Box::new(e)); } } } - // If desktop was completely idle across multiple iterations, - // return zero-copy reference to last frame so stream stays active without heap thrashing + // If desktop was completely idle or recovering, return last frame if let Some(ref last) = self.last_frame { Ok(Frame::Raw { width: self.width, diff --git a/src/encoder.rs b/src/encoder.rs index adab45c..33af681 100644 --- a/src/encoder.rs +++ b/src/encoder.rs @@ -11,6 +11,7 @@ pub struct VaapiEncoder { staged_nv12: *mut AVFrame, hw_frame: *mut AVFrame, has_frame: bool, + pub force_idr: bool, #[cfg(unix)] dma_mmap_cache: HashMap, #[cfg(unix)] @@ -18,6 +19,10 @@ pub struct VaapiEncoder { } impl VaapiEncoder { + pub fn force_keyframe(&mut self) { + self.force_idr = true; + } + pub fn new(width: u32, height: u32) -> Result { Self::new_with_params(width, height, 18_000, 60, "h264") } @@ -180,6 +185,7 @@ impl VaapiEncoder { staged_nv12, hw_frame, has_frame: false, + force_idr: false, #[cfg(unix)] dma_mmap_cache: HashMap::new(), #[cfg(unix)] @@ -203,6 +209,12 @@ impl VaapiEncoder { } (*self.hw_frame).pts = pts; + if self.force_idr { + (*self.hw_frame).pict_type = AVPictureType::AV_PICTURE_TYPE_I; + self.force_idr = false; + } else { + (*self.hw_frame).pict_type = AVPictureType::AV_PICTURE_TYPE_NONE; + } if avcodec_send_frame(self.codec_ctx, self.hw_frame) >= 0 { let mut pkt = av_packet_alloc(); @@ -416,10 +428,15 @@ pub struct WindowsHwEncoder { sw_frame: *mut AVFrame, pub has_frame: bool, native_bgr: bool, + pub force_idr: bool, } #[cfg(target_os = "windows")] impl WindowsHwEncoder { + pub fn force_keyframe(&mut self) { + self.force_idr = true; + } + pub fn new(width: u32, height: u32) -> Result { Self::new_with_params(width, height, 20_000, 60, "h264") } @@ -580,6 +597,7 @@ impl WindowsHwEncoder { sw_frame, has_frame: false, native_bgr: is_native_bgr, + force_idr: false, }) } } @@ -632,6 +650,12 @@ impl WindowsHwEncoder { } (*self.sw_frame).pts = pts; + if self.force_idr { + (*self.sw_frame).pict_type = AVPictureType::AV_PICTURE_TYPE_I; + self.force_idr = false; + } else { + (*self.sw_frame).pict_type = AVPictureType::AV_PICTURE_TYPE_NONE; + } if avcodec_send_frame(self.codec_ctx, self.sw_frame) >= 0 { let mut pkt = av_packet_alloc(); @@ -647,6 +671,12 @@ impl WindowsHwEncoder { #[cfg(target_os = "windows")] Frame::D3D11Texture { handle: _, .. } => { (*self.sw_frame).pts = pts; + if self.force_idr { + (*self.sw_frame).pict_type = AVPictureType::AV_PICTURE_TYPE_I; + self.force_idr = false; + } else { + (*self.sw_frame).pict_type = AVPictureType::AV_PICTURE_TYPE_NONE; + } if avcodec_send_frame(self.codec_ctx, self.sw_frame) >= 0 { let mut pkt = av_packet_alloc(); @@ -676,6 +706,12 @@ impl WindowsHwEncoder { return Ok(packets); } (*self.sw_frame).pts = pts; + if self.force_idr { + (*self.sw_frame).pict_type = AVPictureType::AV_PICTURE_TYPE_I; + self.force_idr = false; + } else { + (*self.sw_frame).pict_type = AVPictureType::AV_PICTURE_TYPE_NONE; + } if avcodec_send_frame(self.codec_ctx, self.sw_frame) >= 0 { let mut pkt = av_packet_alloc(); diff --git a/src/overlay_egui.rs b/src/overlay_egui.rs index 46f7ed2..05fb7dd 100644 --- a/src/overlay_egui.rs +++ b/src/overlay_egui.rs @@ -554,6 +554,10 @@ fn render_keycap_button( // Modern squared button matching GPU Screen Recorder Button styling fn squared_button(ui: &mut egui::Ui, text: &str, active: bool, accent: Color32) -> bool { + squared_button_sized(ui, text, active, accent, None) +} + +fn squared_button_sized(ui: &mut egui::Ui, text: &str, active: bool, accent: Color32, min_size: Option) -> bool { let fill = if active { accent } else { @@ -562,7 +566,7 @@ fn squared_button(ui: &mut egui::Ui, text: &str, active: bool, accent: Color32) let stroke = if active { Stroke::new(1.0_f32, accent) } else { - Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 20)) + Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(255, 255, 255, 22)) }; let text_color = if active { if accent.r() as u16 + accent.g() as u16 + accent.b() as u16 > 400 { @@ -573,10 +577,12 @@ fn squared_button(ui: &mut egui::Ui, text: &str, active: bool, accent: Color32) } else { Color32::from_rgb(220, 220, 225) }; + let sz = min_size.unwrap_or(Vec2::new(36.0, 26.0)); let btn = egui::Button::new(egui::RichText::new(text).size(11.5).strong().color(text_color)) .fill(fill) .stroke(stroke) - .corner_radius(CornerRadius::ZERO); + .corner_radius(CornerRadius::ZERO) + .min_size(sz); ui.add(btn).clicked() } @@ -961,6 +967,7 @@ pub struct ScytheOverlayApp { autostart_replay: bool, autostart_overlay: bool, pub settings_view_advanced: bool, + last_content_h: f32, hud_notification: Option, } @@ -1084,6 +1091,7 @@ impl ScytheOverlayApp { autostart_replay, autostart_overlay, settings_view_advanced: false, + last_content_h: 520.0, hud_notification: None, } } @@ -1221,6 +1229,7 @@ impl ScytheOverlayApp { self.replay_dropdown_open = false; self.record_dropdown_open = false; self.stream_dropdown_open = false; + self.last_content_h = 520.0; } fn render_top_bar(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { @@ -1613,7 +1622,8 @@ impl ScytheOverlayApp { // Exact GsrPage geometry formula from scratch/gsr-ui/src/gui/GsrPage.cpp: // content_page_size = (window_size * vec2(0.3333f, 0.70f)).floor(); let content_w = (screen_w * 0.3333).clamp(580.0, 680.0); - let content_h = (screen_h * 0.70).clamp(520.0, 780.0); + let max_content_h = (screen_h * 0.72).clamp(460.0, 780.0); + let content_h = self.last_content_h.clamp(360.0, max_content_h); let spacing = (screen_w / 50.0).clamp(20.0, 38.0); let side_w = (screen_w / 10.0).clamp(160.0, 192.0); @@ -1825,14 +1835,17 @@ impl ScytheOverlayApp { ShadowPlayView::ReplaySettings | ShadowPlayView::RecordSettings | ShadowPlayView::StreamSettings ); - ui.allocate_new_ui(egui::UiBuilder::new().max_rect(content_rect), |ui| { - ui.add_space(14.0); + let inner_rect = content_rect.shrink2(egui::vec2(16.0, 12.0)); + + ui.allocate_new_ui(egui::UiBuilder::new().max_rect(inner_rect), |ui| { + ui.set_width(inner_rect.width()); + ui.add_space(4.0); // [ Simple view ] [ Advanced view ] Horizontal Radio Buttons if has_view_toggle { ui.horizontal(|ui| { let total_radio_w = 260.0; - let pad = ((content_w - total_radio_w) * 0.5).max(0.0); + let pad = ((inner_rect.width() - total_radio_w) * 0.5).max(0.0); ui.add_space(pad); let item_w = 126.0; @@ -1916,15 +1929,15 @@ impl ScytheOverlayApp { self.settings_view_advanced = true; } }); - ui.add_space(14.0); + ui.add_space(12.0); } - let scroll_h = content_h - (if has_view_toggle { 70.0 } else { 30.0 }); - egui::ScrollArea::vertical() + let scroll_h = inner_rect.height() - (if has_view_toggle { 48.0 } else { 8.0 }); + let scroll_resp = egui::ScrollArea::vertical() .max_height(scroll_h) - .auto_shrink([false, false]) + .auto_shrink([false, true]) .show(ui, |ui| { - ui.set_width(content_w - 36.0); + ui.set_width(inner_rect.width()); ui.add_space(2.0); match self.current_view { @@ -1934,11 +1947,12 @@ impl ScytheOverlayApp { ui.label(egui::RichText::new("Directory to save replays:").size(12.0).color(Color32::WHITE)); ui.add_space(3.0); ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut self.output_dir).desired_width(ui.available_width() - 140.0)); - if squared_button(ui, "Change", false, accent) { + let edit_w = (ui.available_width() - 152.0).max(120.0); + ui.add(egui::TextEdit::singleline(&mut self.output_dir).desired_width(edit_w)); + if squared_button_sized(ui, "Change", false, accent, Some(egui::vec2(70.0, 26.0))) { pick_folder(&self.output_dir, self.folder_tx.clone(), self.folder_picking_active.clone()); } - if squared_button(ui, "Open", false, accent) { + if squared_button_sized(ui, "Open", false, accent, Some(egui::vec2(60.0, 26.0))) { open_folder(&ScytheConfig::expand_tilde(&self.output_dir)); } }); @@ -2155,11 +2169,12 @@ impl ScytheOverlayApp { ui.label(egui::RichText::new("Directory to save recordings:").size(12.0).color(Color32::WHITE)); ui.add_space(3.0); ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut self.output_dir).desired_width(ui.available_width() - 140.0)); - if squared_button(ui, "Change", false, accent) { + let edit_w = (ui.available_width() - 152.0).max(120.0); + ui.add(egui::TextEdit::singleline(&mut self.output_dir).desired_width(edit_w)); + if squared_button_sized(ui, "Change", false, accent, Some(egui::vec2(70.0, 26.0))) { pick_folder(&self.output_dir, self.folder_tx.clone(), self.folder_picking_active.clone()); } - if squared_button(ui, "Open", false, accent) { + if squared_button_sized(ui, "Open", false, accent, Some(egui::vec2(60.0, 26.0))) { open_folder(&ScytheConfig::expand_tilde(&self.output_dir)); } }); @@ -2461,9 +2476,9 @@ impl ScytheOverlayApp { ); } - ui.add_space(6.0); + ui.add_space(8.0); ui.horizontal(|ui| { - if squared_button(ui, "Reset Hotkeys to Defaults", false, accent) { + if squared_button_sized(ui, "Reset Hotkeys to Defaults", false, accent, Some(Vec2::new(180.0, 28.0))) { let old_menu = self.config.menu_hotkey.clone(); let old_save = self.config.save_hotkey.clone(); let old_rec = self.config.record_hotkey.clone(); @@ -2490,58 +2505,77 @@ impl ScytheOverlayApp { render_section_card(ui, "Appearance", accent, |ui| { ui.label(egui::RichText::new("Interface Accent Color:").size(12.0).color(Color32::WHITE)); ui.add_space(8.0); - ui.horizontal_wrapped(|ui| { - let palettes = [ - ("amd", "AMD Radeon Red", Color32::from_rgb(221, 0, 49)), - ("nvidia", "NVIDIA GeForce Green", Color32::from_rgb(118, 185, 0)), - ("intel", "Intel Arc Blue", Color32::from_rgb(8, 109, 183)), - ("blue", "Charming Blue", Color32::from_rgb(56, 189, 248)), - ("green", "Emerald Green", Color32::from_rgb(34, 197, 94)), - ("yellow", "Solar Yellow", Color32::from_rgb(250, 204, 21)), - ("purple", "Royal Purple", Color32::from_rgb(168, 85, 247)), - ("pink", "Neon Pink", Color32::from_rgb(244, 63, 94)), - ]; - for (id, name, col) in palettes { - let is_sel = self.config.accent_color.to_lowercase() == id; - let bg = if is_sel { - col - } else { - Color32::from_rgba_unmultiplied(0, 0, 0, 120) - }; - let stroke = if is_sel { - Stroke::new(1.5_f32, Color32::WHITE) - } else { - Stroke::new(1.0_f32, col) - }; - let text_col = if is_sel { - if col.r() as u16 + col.g() as u16 + col.b() as u16 > 400 { - Color32::BLACK + let palettes: [(&str, &str, Color32); 8] = [ + ("amd", "AMD Radeon Red", Color32::from_rgb(221, 0, 49)), + ("nvidia", "NVIDIA GeForce Green", Color32::from_rgb(118, 185, 0)), + ("intel", "Intel Arc Blue", Color32::from_rgb(8, 109, 183)), + ("blue", "Charming Blue", Color32::from_rgb(56, 189, 248)), + ("green", "Emerald Green", Color32::from_rgb(34, 197, 94)), + ("yellow", "Solar Yellow", Color32::from_rgb(250, 204, 21)), + ("purple", "Royal Purple", Color32::from_rgb(168, 85, 247)), + ("pink", "Neon Pink", Color32::from_rgb(244, 63, 94)), + ]; + + let total_w = ui.available_width(); + let gap = 6.0_f32; + let cols = 4; + let btn_w = ((total_w - (cols - 1) as f32 * gap) / cols as f32).floor(); + let btn_h = 32.0_f32; + + for chunk in palettes.chunks(cols) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = gap; + for &(id, name, col) in chunk { + let is_sel = self.config.accent_color.to_lowercase() == id; + let (rect, resp) = ui.allocate_exact_size(egui::vec2(btn_w, btn_h), egui::Sense::click()); + let is_hovered = resp.hovered(); + + let bg = if is_sel { + col + } else if is_hovered { + Color32::from_rgba_unmultiplied(255, 255, 255, 18) } else { - Color32::WHITE + Color32::from_rgba_unmultiplied(0, 0, 0, 140) + }; + + let stroke = if is_sel { + Stroke::new(1.5_f32, Color32::WHITE) + } else if is_hovered { + Stroke::new(1.5_f32, col) + } else { + Stroke::new(1.0_f32, col) + }; + + let text_col = if is_sel { + if col.r() as u16 + col.g() as u16 + col.b() as u16 > 400 { + Color32::BLACK + } else { + Color32::WHITE + } + } else { + Color32::from_rgb(225, 225, 230) + }; + + ui.painter().rect_filled(rect, CornerRadius::ZERO, bg); + ui.painter().rect_stroke(rect, CornerRadius::ZERO, stroke, egui::StrokeKind::Inside); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + name, + FontId::proportional(11.0), + text_col, + ); + + if resp.clicked() { + self.config.accent_color = id.to_string(); + let _ = self.config.save(); + self.show_hud_notification("THEME ACCENT", &format!("Selected: {}", name), crate::overlay::ToastIcon::Info); } - } else { - Color32::from_rgb(225, 225, 230) - }; - - let btn = egui::Button::new( - egui::RichText::new(name) - .size(11.5) - .strong() - .color(text_col), - ) - .fill(bg) - .stroke(stroke) - .corner_radius(CornerRadius::ZERO) - .min_size(Vec2::new(132.0, 30.0)); - - if ui.add(btn).clicked() { - self.config.accent_color = id.to_string(); - let _ = self.config.save(); - self.show_hud_notification("THEME ACCENT", &format!("Selected: {}", name), crate::overlay::ToastIcon::Info); } - } - }); + }); + ui.add_space(gap); + } }); ui.add_space(12.0); @@ -2560,7 +2594,7 @@ impl ScytheOverlayApp { let cur_status = self.update_status.lock().ok().map(|g| g.clone()).unwrap_or_default(); match cur_status { crate::updater::UpdateStatus::Idle => { - if squared_button(ui, "Check for Updates", false, accent) { + if squared_button_sized(ui, "Check for Updates", false, accent, Some(egui::vec2(130.0, 26.0))) { crate::updater::spawn_update_check(self.update_status.clone()); } } @@ -2579,7 +2613,7 @@ impl ScytheOverlayApp { .strong(), ); ui.add_space(6.0); - if squared_button(ui, "Check Again", false, accent) { + if squared_button_sized(ui, "Check Again", false, accent, Some(egui::vec2(90.0, 26.0))) { crate::updater::spawn_update_check(self.update_status.clone()); } } @@ -2591,7 +2625,7 @@ impl ScytheOverlayApp { .strong(), ); ui.add_space(6.0); - if squared_button(ui, "VIEW RELEASE / DOWNLOAD", true, accent) { + if squared_button_sized(ui, "VIEW RELEASE / DOWNLOAD", true, accent, Some(egui::vec2(180.0, 26.0))) { crate::updater::open_browser_url(&info.html_url); } } @@ -2602,7 +2636,7 @@ impl ScytheOverlayApp { .color(Color32::from_rgb(239, 68, 68)), ); ui.add_space(6.0); - if squared_button(ui, "Retry", false, accent) { + if squared_button_sized(ui, "Retry", false, accent, Some(egui::vec2(70.0, 26.0))) { crate::updater::spawn_update_check(self.update_status.clone()); } } @@ -2633,11 +2667,12 @@ impl ScytheOverlayApp { ui.label(egui::RichText::new("Directory to save screenshots:").size(12.0).color(Color32::WHITE)); ui.add_space(3.0); ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut self.output_dir).desired_width(ui.available_width() - 140.0)); - if squared_button(ui, "Change", false, accent) { + let edit_w = (ui.available_width() - 152.0).max(120.0); + ui.add(egui::TextEdit::singleline(&mut self.output_dir).desired_width(edit_w)); + if squared_button_sized(ui, "Change", false, accent, Some(egui::vec2(70.0, 26.0))) { pick_folder(&self.output_dir, self.folder_tx.clone(), self.folder_picking_active.clone()); } - if squared_button(ui, "Open", false, accent) { + if squared_button_sized(ui, "Open", false, accent, Some(egui::vec2(60.0, 26.0))) { open_folder(&ScytheConfig::expand_tilde(&self.output_dir)); } }); @@ -2673,6 +2708,9 @@ impl ScytheOverlayApp { _ => {} } }); + + let needed_h = scroll_resp.content_size.y + (if has_view_toggle { 52.0 } else { 12.0 }) + 36.0; + self.last_content_h = (self.last_content_h * 0.7 + needed_h * 0.3).clamp(360.0, max_content_h); }); } fn render_gallery_view(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { @@ -3335,13 +3373,8 @@ impl eframe::App for ScytheOverlayApp { } self.frame_count += 1; - // Auto-position, DWM transparency, and size to monitor on launch - if !self.initial_pos_set && self.frame_count >= 2 { - if let Some(monitor_size) = ctx.input(|i| i.viewport().monitor_size) - && monitor_size.x > 100.0 && monitor_size.y > 100.0 { - ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2(0.0, 0.0))); - ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(monitor_size)); - } + // Apply transparency and make visible smoothly on first frame + if !self.initial_pos_set { #[cfg(target_os = "windows")] apply_windows_transparency("Scythe"); ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); @@ -3552,20 +3585,11 @@ pub fn apply_windows_transparency(title: &str) { if let Some(proc) = GetProcAddress(user32, windows::core::s!("SetWindowCompositionAttribute")) { let set_wca: SetWindowCompositionAttributeFn = std::mem::transmute(proc); - let mut policy = if is_overlay { - AccentPolicy { - accent_state: 3, // ACCENT_ENABLE_BLURBEHIND - accent_flags: 2, - gradient_color: 0x99101014, // Translucent dark obsidian tint - animation_id: 0, - } - } else { - AccentPolicy { - accent_state: 2, // ACCENT_ENABLE_TRANSPARENTGRADIENT - accent_flags: 2, - gradient_color: 0x00000000, // Fully transparent - animation_id: 0, - } + let mut policy = AccentPolicy { + accent_state: 2, // ACCENT_ENABLE_TRANSPARENTGRADIENT (true per-pixel alpha on Win 10 & 11) + accent_flags: 2, + gradient_color: 0x00000000, // Fully transparent backbuffer + animation_id: 0, }; let mut data = WindowCompositionAttributeData {