fix(windows): resolve capture freeze, duplicate hotkey saves, overlay transparency, and settings UI alignment
Some checks are pending
Release & Packaging / Build Linux Packages (.tar.gz, .deb, .rpm, .AppImage) (push) Waiting to run
Release & Packaging / Build Windows Binaries (.exe) (push) Waiting to run
Release & Packaging / Publish GitHub Release (push) Blocked by required conditions

This commit is contained in:
katR 2026-09-18 19:48:43 +05:30
parent aa1c6643b8
commit b2d18297da
7 changed files with 289 additions and 157 deletions

2
Cargo.lock generated
View file

@ -3700,7 +3700,7 @@ dependencies = [
[[package]]
name = "scythe"
version = "0.2.14"
version = "0.2.15"
dependencies = [
"ashpd",
"cpal",

View file

@ -1,6 +1,6 @@
[package]
name = "scythe"
version = "0.2.14"
version = "0.2.15"
edition = "2024"
[[bin]]

View file

@ -461,6 +461,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut normal_muxer: Option<Muxer> = 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<dyn std::error::Error + Send + Sync>> {
},
Command::SaveReplay => {
if config.replay_enabled {
let drain = ring.iter().cloned().collect::<Vec<_>>();
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::<Vec<_>>();
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
}
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<dyn std::error::Error + Send + Sync>> {
}
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<dyn std::error::Error + Send + Sync>> {
} 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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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

View file

@ -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<dyn std::error::Error + Send + Sync>> {
}
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();

View file

@ -15,7 +15,7 @@ use std::sync::Arc;
pub struct WindowsCapture {
device: ID3D11Device,
context: ID3D11DeviceContext,
duplication: IDXGIOutputDuplication,
duplication: Option<IDXGIOutputDuplication>,
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<dyn std::error::Error + Send + Sync>> {
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::<IDXGIOutput1>() {
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<ID3D11Texture2D> = None;
let mut tex1: Option<ID3D11Texture2D> = 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<ID3D11Texture2D> = None;
let mut tex1: Option<ID3D11Texture2D> = 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<Frame, Box<dyn std::error::Error + Send + Sync>> {
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<IDXGIResource> = 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,

View file

@ -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<i32, (*mut libc::c_void, usize)>,
#[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, String> {
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, String> {
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();

View file

@ -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<Vec2>) -> 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<HudNotification>,
}
@ -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 {