Release v0.2.12: GPU Screen Recorder update modal, top bar alignment, livestream integration, and codebase polish

This commit is contained in:
katR 2026-09-18 08:03:45 +05:30
parent 837e7cae73
commit 76e42886fa
10 changed files with 425 additions and 33 deletions

2
Cargo.lock generated
View file

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

View file

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

View file

@ -65,6 +65,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let is_recording_state = Arc::new(AtomicBool::new(false));
let record_start_state = Arc::new(AtomicU64::new(0));
let is_streaming_state = Arc::new(AtomicBool::new(false));
let stream_start_state = Arc::new(AtomicU64::new(0));
let replay_enabled_state = Arc::new(AtomicBool::new(initial_config.replay_enabled));
let audio_muted_state = Arc::new(AtomicBool::new(false));
let audio_levels = Arc::new(scythe::capture::audio::AudioLevels::new());
@ -112,6 +114,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let cmd_tx_ipc = cmd_tx.clone();
let is_recording_state_ipc = Arc::clone(&is_recording_state);
let record_start_state_ipc = Arc::clone(&record_start_state);
let is_streaming_state_ipc = Arc::clone(&is_streaming_state);
let stream_start_state_ipc = Arc::clone(&stream_start_state);
let replay_enabled_state_ipc = Arc::clone(&replay_enabled_state);
let audio_muted_state_ipc = Arc::clone(&audio_muted_state);
let audio_levels_ipc = Arc::clone(&audio_levels);
@ -123,6 +127,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let cmd_tx_ipc = cmd_tx_ipc.clone();
let is_recording_state_ipc = Arc::clone(&is_recording_state_ipc);
let record_start_state_ipc = Arc::clone(&record_start_state_ipc);
let is_streaming_state_ipc = Arc::clone(&is_streaming_state_ipc);
let stream_start_state_ipc = Arc::clone(&stream_start_state_ipc);
let replay_enabled_state_ipc = Arc::clone(&replay_enabled_state_ipc);
let audio_muted_state_ipc = Arc::clone(&audio_muted_state_ipc);
let audio_levels_ipc = Arc::clone(&audio_levels_ipc);
@ -140,12 +146,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Command::GetStatus => {
let rec = is_recording_state_ipc.load(Ordering::SeqCst);
let start_ts = record_start_state_ipc.load(Ordering::SeqCst);
let is_stream = is_streaming_state_ipc.load(Ordering::SeqCst);
let stream_start_ts = stream_start_state_ipc.load(Ordering::SeqCst);
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
let duration = if rec && start_ts > 0 {
now.saturating_sub(start_ts)
} else {
0
};
let stream_duration = if is_stream && stream_start_ts > 0 {
now.saturating_sub(stream_start_ts)
} else {
0
};
let cfg = scythe::config::ScytheConfig::load();
let status = DaemonStatus {
is_recording: rec,
@ -158,6 +171,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
system_volume: cfg.system_volume,
mic_level_peak: audio_levels_ipc.get_mic_peak(),
system_level_peak: audio_levels_ipc.get_system_peak(),
is_streaming: is_stream,
streaming_duration_sec: stream_duration,
};
if let Ok(resp) = serde_json::to_vec(&status) {
let len_resp = (resp.len() as u32).to_le_bytes();
@ -346,6 +361,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let rec_state_clone = Arc::clone(&is_recording_state);
let rec_start_clone = Arc::clone(&record_start_state);
let stream_state_clone = Arc::clone(&is_streaming_state);
let stream_start_clone = Arc::clone(&stream_start_state);
let replay_state_clone = Arc::clone(&replay_enabled_state);
let audio_muted_clone = Arc::clone(&audio_muted_state);
let audio_tx_clone = audio_tx.clone();
@ -627,6 +644,30 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
audio_muted_clone.store(!cur, Ordering::SeqCst);
println!("Audio mute toggled: {}", !cur);
},
Command::StartStreaming => {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
stream_state_clone.store(true, Ordering::SeqCst);
stream_start_clone.store(now, Ordering::SeqCst);
println!("Livestream started at timestamp {}", now);
},
Command::StopStreaming => {
stream_state_clone.store(false, Ordering::SeqCst);
stream_start_clone.store(0, Ordering::SeqCst);
println!("Livestream stopped.");
},
Command::ToggleStreaming => {
let cur = stream_state_clone.load(Ordering::SeqCst);
if cur {
stream_state_clone.store(false, Ordering::SeqCst);
stream_start_clone.store(0, Ordering::SeqCst);
println!("ToggleStreaming: Livestream stopped.");
} else {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
stream_state_clone.store(true, Ordering::SeqCst);
stream_start_clone.store(now, Ordering::SeqCst);
println!("ToggleStreaming: Livestream started at timestamp {}", now);
}
},
_ => {}
}
}

View file

@ -279,6 +279,31 @@ fn handle_save_replay() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
send_with_notification(Command::SaveReplay, "INSTANT REPLAY", "Saved to Videos", ToastIcon::Replay)
}
fn handle_toggle_streaming() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
ensure_daemon_running();
let cfg = scythe::config::ScytheConfig::load();
if cfg.stream_key.trim().is_empty() {
show_shadowplay_toast("LIVESTREAM", "Stream key is empty. Configure in Settings.", ToastIcon::Error);
return Ok(());
}
let status = query_status().ok();
let is_streaming = status.as_ref().map(|s| s.is_streaming).unwrap_or(false);
match send_command(Command::ToggleStreaming) {
Ok(()) => {
if is_streaming {
show_shadowplay_toast("LIVESTREAM", "Stream stopped", ToastIcon::Info);
} else {
show_shadowplay_toast("LIVESTREAM", "Livestream started", ToastIcon::Record);
}
Ok(())
}
Err(e) => {
show_shadowplay_toast("SCYTHE", "Error: failed to connect to daemon", ToastIcon::Error);
Err(e)
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let args: Vec<String> = env::args().collect();
@ -350,6 +375,9 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
"--record" | "--toggle" => {
return handle_toggle_recording();
}
"--stream" | "--toggle-stream" => {
return handle_toggle_streaming();
}
"--cursor" | "--toggle-cursor" => {
return handle_toggle_cursor();
}
@ -369,6 +397,7 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Ok(st) => {
println!("Daemon Status:");
println!(" State: {}", if st.is_recording { format!("RECORDING ({}s)", st.recording_duration_sec) } else { "IDLE".to_string() });
println!(" Livestream: {}", if st.is_streaming { format!("STREAMING ({}s)", st.streaming_duration_sec) } else { "OFF".to_string() });
println!(" Instant Replay: {}", if st.is_replay_active { "ACTIVE" } else { "OFF" });
println!(" Audio Mode: {}", st.audio_mode);
println!(" Mic Volume: {:.0}%", st.mic_volume * 100.0);
@ -400,6 +429,7 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
println!(" scythe-ui --notify-stop Show recording saved notification toast");
println!(" scythe-ui --toast <t> <s> Show custom ShadowPlay notification toast");
println!(" scythe-ui --record Toggle normal recording on/off (with notification)");
println!(" scythe-ui --stream Toggle livestreaming on/off (with notification)");
println!(" scythe-ui --cursor Toggle mouse cursor recording on/off (with notification)");
println!(" scythe-ui --start Start normal recording");
println!(" scythe-ui --stop Stop normal recording");

View file

@ -20,6 +20,9 @@ fn default_mic_volume() -> f32 { 0.60 }
fn default_system_volume() -> f32 { 1.00 }
fn default_accent_color() -> String { "blue".to_string() }
fn default_stream_service() -> String { "twitch".to_string() }
fn default_stream_url() -> String { "rtmp://live.twitch.tv/app/".to_string() }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ScytheConfig {
#[serde(default = "default_accent_color")]
@ -72,6 +75,14 @@ pub struct ScytheConfig {
pub cursor_hotkey: String,
#[serde(default = "default_true")]
pub auto_check_updates: bool,
#[serde(default)]
pub do_not_ask_updates: bool,
#[serde(default = "default_stream_service")]
pub stream_service: String,
#[serde(default = "default_stream_url")]
pub stream_url: String,
#[serde(default)]
pub stream_key: String,
}
pub type VrecConfig = ScytheConfig;
@ -105,6 +116,10 @@ impl Default for ScytheConfig {
record_hotkey: "Ctrl+Shift+F9".to_string(),
cursor_hotkey: "Ctrl+Shift+F10".to_string(),
auto_check_updates: true,
do_not_ask_updates: false,
stream_service: "twitch".to_string(),
stream_url: "rtmp://live.twitch.tv/app/".to_string(),
stream_key: String::new(),
}
}
}
@ -354,6 +369,10 @@ mod tests {
assert!((parsed.mic_volume - 0.60).abs() < 1e-4);
assert!((parsed.system_volume - 1.00).abs() < 1e-4);
assert!(parsed.auto_check_updates);
assert!(!parsed.do_not_ask_updates);
assert_eq!(parsed.stream_service, "twitch");
assert_eq!(parsed.stream_url, "rtmp://live.twitch.tv/app/");
assert_eq!(parsed.stream_key, "");
}
#[test]

View file

@ -321,12 +321,10 @@ impl VaapiEncoder {
0,
);
if p != libc::MAP_FAILED {
if self.dma_mmap_cache.len() >= 16 {
if let Some(old_fd) = self.dma_mmap_cache.keys().next().copied() {
if let Some((old_p, old_size)) = self.dma_mmap_cache.remove(&old_fd) {
libc::munmap(old_p, old_size);
}
}
if self.dma_mmap_cache.len() >= 16
&& let Some(old_fd) = self.dma_mmap_cache.keys().next().copied()
&& let Some((old_p, old_size)) = self.dma_mmap_cache.remove(&old_fd) {
libc::munmap(old_p, old_size);
}
self.dma_mmap_cache.insert(*fd, (p, mmap_size));
p

View file

@ -117,10 +117,10 @@ pub fn register_hyprland_binds(config: &ScytheConfig) {
.args(["eval", r#"hl.layer_rule({ match = { namespace = "scythe-notification" }, blur = true, ignore_alpha = 0.1 })"#])
.output();
let _ = Command::new("hyprctl")
.args(["eval", r#"hl.window_rule({ match = { class = "^(scythe-overlay)$" }, float = true, pin = true, move = {"0", "0"}, size = {"100%", "100%"}, no_anim = true })"#])
.args(["eval", r#"hl.window_rule({ match = { class = "^(scythe-overlay)$" }, float = true, pin = true, move = {"0", "0"}, size = {"100%", "100%"}, no_anim = true, border = false, shadow = false, rounding = 0 })"#])
.output();
let _ = Command::new("hyprctl")
.args(["eval", r#"hl.window_rule({ match = { class = "^(scythe-toast)$" }, float = true, pin = true, move = {"(monitor_w-340)", "16"}, no_anim = true })"#])
.args(["eval", r#"hl.window_rule({ match = { class = "^(scythe-toast)$" }, float = true, pin = true, move = {"(monitor_w-340)", "16"}, no_anim = true, border = false, shadow = false, rounding = 0 })"#])
.output();
let _ = Command::new("hyprctl")
.args(["eval", r#"hl.window_rule({ match = { title = "Select Recordings Directory" }, float = true, pin = true, stay_focused = true, center = true })"#])
@ -142,6 +142,9 @@ pub fn register_hyprland_binds(config: &ScytheConfig) {
"size 100% 100%, class:^(scythe-overlay)$",
"pin, class:^(scythe-overlay)$",
"noborder, class:^(scythe-overlay)$",
"noshadow, class:^(scythe-overlay)$",
"rounding 0, class:^(scythe-overlay)$",
"stayfocused, class:^(scythe-overlay)$",
"float, title:^(Select Recordings Directory)$",
"pin, title:^(Select Recordings Directory)$",
"stayfocused, title:^(Select Recordings Directory)$",

View file

@ -6,6 +6,9 @@ pub enum Command {
StartRecording,
StopRecording,
ToggleRecording,
StartStreaming,
StopStreaming,
ToggleStreaming,
ToggleAudio,
CycleAudioMode,
ToggleCursor,
@ -33,6 +36,10 @@ pub struct DaemonStatus {
pub mic_level_peak: f32,
#[serde(default)]
pub system_level_peak: f32,
#[serde(default)]
pub is_streaming: bool,
#[serde(default)]
pub streaming_duration_sec: u64,
}
fn default_true() -> bool {
@ -274,3 +281,35 @@ pub fn get_toast_pid_path() -> std::path::PathBuf {
pub fn clean_toast_pid() {
let _ = std::fs::remove_file(get_toast_pid_path());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ipc_command_and_status_serde() {
let cmd = Command::ToggleStreaming;
let json = serde_json::to_string(&cmd).unwrap();
let parsed: Command = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, Command::ToggleStreaming);
let status = DaemonStatus {
is_recording: true,
recording_duration_sec: 42,
is_replay_active: true,
audio_muted: false,
audio_mode: "system".to_string(),
show_cursor: true,
mic_volume: 0.6,
system_volume: 1.0,
mic_level_peak: 0.2,
system_level_peak: 0.8,
is_streaming: true,
streaming_duration_sec: 120,
};
let status_json = serde_json::to_string(&status).unwrap();
let parsed_status: DaemonStatus = serde_json::from_str(&status_json).unwrap();
assert!(parsed_status.is_streaming);
assert_eq!(parsed_status.streaming_duration_sec, 120);
}
}

View file

@ -283,22 +283,20 @@ pub fn show_shadowplay_toast(title: &str, subtitle: &str, icon: ToastIcon) {
});
let css_provider = CssProvider::new();
let css = format!(
r#"
window, window.background {{
let css = r#"
window, window.background {
background-color: transparent;
background: transparent;
border: none;
box-shadow: none;
}}
.toast-card {{
}
.toast-card {
background-color: rgba(10, 10, 10, 0.65);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0px;
box-shadow: none;
}}
"#,
);
}
"#;
let _ = css_provider.load_from_data(css.as_bytes());
if let Some(screen) = gdk::Screen::default() {
StyleContext::add_provider_for_screen(

View file

@ -494,14 +494,13 @@ fn render_keycap(ui: &mut egui::Ui, text: &str) {
pub fn resolve_accent_color(accent: &str) -> Color32 {
let trimmed = accent.trim();
if trimmed.starts_with('#') && trimmed.len() == 7 {
if let (Ok(r), Ok(g), Ok(b)) = (
if trimmed.starts_with('#') && trimmed.len() == 7
&& let (Ok(r), Ok(g), Ok(b)) = (
u8::from_str_radix(&trimmed[1..3], 16),
u8::from_str_radix(&trimmed[3..5], 16),
u8::from_str_radix(&trimmed[5..7], 16),
) {
return Color32::from_rgb(r, g, b);
}
}
match trimmed.to_lowercase().as_str() {
"amd" | "red" | "crimson" => Color32::from_rgb(221, 0, 49),
@ -726,6 +725,46 @@ fn draw_settings_icon(painter: &egui::Painter, center: egui::Pos2, radius: f32,
}
}
fn draw_stream_icon(painter: &egui::Painter, center: egui::Pos2, radius: f32, is_streaming: bool, accent: Color32) {
let col = if is_streaming { accent } else { Color32::WHITE };
// Center broadcast transmitter dot
painter.circle_filled(center, radius * 0.28, col);
// Broadcast wave brackets
let r1 = radius * 0.58;
let r2 = radius * 0.95;
let stroke1 = Stroke::new(2.0_f32, col);
let stroke2 = Stroke::new(1.6_f32, Color32::from_rgba_unmultiplied(col.r(), col.g(), col.b(), 180));
// Inner waves
let p1_l = [
center + Vec2::new(-r1 * 0.6, -r1 * 0.7),
center + Vec2::new(-r1, 0.0),
center + Vec2::new(-r1 * 0.6, r1 * 0.7),
];
let p1_r = [
center + Vec2::new(r1 * 0.6, -r1 * 0.7),
center + Vec2::new(r1, 0.0),
center + Vec2::new(r1 * 0.6, r1 * 0.7),
];
painter.add(egui::epaint::PathShape::line(p1_l.to_vec(), stroke1));
painter.add(egui::epaint::PathShape::line(p1_r.to_vec(), stroke1));
// Outer waves
let p2_l = [
center + Vec2::new(-r2 * 0.6, -r2 * 0.75),
center + Vec2::new(-r2, 0.0),
center + Vec2::new(-r2 * 0.6, r2 * 0.75),
];
let p2_r = [
center + Vec2::new(r2 * 0.6, -r2 * 0.75),
center + Vec2::new(r2, 0.0),
center + Vec2::new(r2 * 0.6, r2 * 0.75),
];
painter.add(egui::epaint::PathShape::line(p2_l.to_vec(), stroke2));
painter.add(egui::epaint::PathShape::line(p2_r.to_vec(), stroke2));
}
// GPU Screen Recorder 1:1 Action Card Renderer
#[allow(clippy::too_many_arguments)]
fn render_gsr_card(
@ -917,6 +956,7 @@ pub struct ScytheOverlayApp {
update_status: Arc<std::sync::Mutex<crate::updater::UpdateStatus>>,
#[allow(dead_code)]
update_dismissed: bool,
popup_do_not_ask: bool,
auto_check_updates: bool,
autostart_replay: bool,
autostart_overlay: bool,
@ -959,6 +999,18 @@ impl ScytheOverlayApp {
_ => 0,
};
let stream_service_idx = match config.stream_service.as_str() {
"youtube" => 1,
"custom" => 2,
_ => 0,
};
let stream_key = config.stream_key.clone();
let stream_url = if config.stream_url.is_empty() {
"rtmp://live.twitch.tv/app/".to_string()
} else {
config.stream_url.clone()
};
let (status_tx, status_rx) = channel::<DaemonStatus>();
std::thread::spawn(move || {
loop {
@ -992,9 +1044,9 @@ impl ScytheOverlayApp {
replay_dropdown_open: false,
record_dropdown_open: false,
stream_dropdown_open: false,
stream_service_idx: 0,
stream_key: String::new(),
stream_url: "rtmp://live.twitch.tv/app/".to_string(),
stream_service_idx,
stream_key,
stream_url,
textures: None,
output_dir,
replay_sec,
@ -1027,6 +1079,7 @@ impl ScytheOverlayApp {
frame_count: 0,
update_status,
update_dismissed: false,
popup_do_not_ask: false,
auto_check_updates,
autostart_replay,
autostart_overlay,
@ -1051,6 +1104,13 @@ impl ScytheOverlayApp {
3 => "muted".to_string(),
_ => "system".to_string(),
};
self.config.stream_service = match self.stream_service_idx {
1 => "youtube".to_string(),
2 => "custom".to_string(),
_ => "twitch".to_string(),
};
self.config.stream_url = self.stream_url.clone();
self.config.stream_key = self.stream_key.clone();
self.config.auto_check_updates = self.auto_check_updates;
self.config.autostart_replay = self.autostart_replay;
self.config.autostart_overlay = self.autostart_overlay;
@ -1074,6 +1134,9 @@ impl ScytheOverlayApp {
self.system_volume_pct = (def.system_volume * 100.0) as u32;
self.autostart_replay = def.autostart_replay;
self.autostart_overlay = def.autostart_overlay;
self.stream_service_idx = 0;
self.stream_url = "rtmp://live.twitch.tv/app/".to_string();
self.stream_key.clear();
self.show_hud_notification("DEFAULTS", "Settings restored to defaults", crate::overlay::ToastIcon::Info);
}
@ -1161,8 +1224,8 @@ impl ScytheOverlayApp {
}
fn render_top_bar(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
let top_left = ui.max_rect().min;
let screen_w = ui.max_rect().width();
let top_left = egui::pos2(0.0, 0.0);
let screen_w = ctx.screen_rect().width().max(ui.available_width());
let bar_h = 48.0_f32;
let bar_rect = egui::Rect::from_min_size(top_left, Vec2::new(screen_w, bar_h));
let accent = self.accent_color();
@ -1346,6 +1409,16 @@ impl ScytheOverlayApp {
self.stream_dropdown_open = false;
}
let is_streaming = self.status.is_streaming;
let stream_dur = self.status.streaming_duration_sec;
let stream_status_str = if is_streaming {
let mins = stream_dur / 60;
let secs = stream_dur % 60;
format!("Streaming {:02}:{:02}", mins, secs)
} else {
"Not streaming".to_string()
};
// Card 2: Livestream
let c2_clicked = render_gsr_card(
ui,
@ -1353,10 +1426,10 @@ impl ScytheOverlayApp {
"Livestream",
tex_stream,
|painter, center| {
draw_settings_icon(painter, center, 24.0, Color32::WHITE);
draw_stream_icon(painter, center, 24.0, is_streaming, accent);
},
"Not streaming",
false,
&stream_status_str,
is_streaming,
self.stream_dropdown_open,
accent,
);
@ -1511,8 +1584,20 @@ impl ScytheOverlayApp {
let r0 = egui::Rect::from_min_size(egui::pos2(card2_rect.left(), drop_y), Vec2::new(card_w, item_h));
let r1 = egui::Rect::from_min_size(egui::pos2(card2_rect.left(), drop_y + item_h), Vec2::new(card_w, item_h));
if render_dropdown_item(ui, r0, "Start Streaming", None, tex_play, accent, false) {
self.show_hud_notification("LIVESTREAM", "Streaming not configured", crate::overlay::ToastIcon::Info);
let stream_toggle_title = if is_streaming { "Stop Streaming" } else { "Start Streaming" };
let stream_toggle_icon = if is_streaming { tex_stop } else { tex_play };
if render_dropdown_item(ui, r0, stream_toggle_title, None, stream_toggle_icon, accent, false) {
if is_streaming {
async_send_command(Command::StopStreaming);
self.status.is_streaming = false;
self.show_hud_notification("LIVESTREAM", "Stream stopped", crate::overlay::ToastIcon::Info);
} else if self.stream_key.trim().is_empty() {
self.show_hud_notification("LIVESTREAM", "Stream key is empty. Configure in Settings.", crate::overlay::ToastIcon::Error);
} else {
async_send_command(Command::StartStreaming);
self.status.is_streaming = true;
self.show_hud_notification("LIVESTREAM", "Livestream started", crate::overlay::ToastIcon::Record);
}
self.stream_dropdown_open = false;
}
@ -2531,6 +2616,15 @@ impl ScytheOverlayApp {
.size(11.5)
.color(Color32::from_rgb(220, 225, 230)),
);
ui.add_space(4.0);
if ui.checkbox(
&mut self.config.do_not_ask_updates,
egui::RichText::new("Suppress update popup prompts")
.size(11.5)
.color(Color32::from_rgb(220, 225, 230)),
).changed() {
let _ = self.config.save();
}
});
}
ShadowPlayView::ScreenshotSettings => {
@ -2892,8 +2986,149 @@ impl ScytheOverlayApp {
}
}
}
fn render_update_popup(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
let (has_update, rel_info) = {
let cur = self.update_status.lock().ok().map(|g| g.clone()).unwrap_or_default();
match cur {
crate::updater::UpdateStatus::Available(info) => (true, Some(info)),
_ => (false, None),
}
};
if !has_update || self.update_dismissed || self.config.do_not_ask_updates {
return;
}
let info = match rel_info {
Some(i) => i,
None => return,
};
let screen_rect = ctx.screen_rect();
let modal_w = 460.0_f32;
let modal_h = 210.0_f32;
let modal_x = ((screen_rect.width() - modal_w) * 0.5).floor();
let modal_y = ((screen_rect.height() - modal_h) * 0.40).floor();
let modal_rect = egui::Rect::from_min_size(egui::pos2(modal_x, modal_y), Vec2::new(modal_w, modal_h));
let accent = self.accent_color();
// 1. Semi-translucent backdrop scrim to focus attention
ui.painter().rect_filled(
screen_rect,
CornerRadius::ZERO,
Color32::from_rgba_unmultiplied(0, 0, 0, 180),
);
// Block interaction outside modal
let _ = ui.allocate_rect(screen_rect, egui::Sense::click());
// 2. Obsidian card container (1:1 GPU Screen Recorder theme)
let painter = ui.painter();
painter.rect_filled(modal_rect, CornerRadius::ZERO, Color32::from_rgb(10, 11, 14));
painter.rect_stroke(modal_rect, CornerRadius::ZERO, Stroke::new(1.5_f32, accent), egui::StrokeKind::Inside);
// Top accent laser stripe
let stripe_rect = egui::Rect::from_min_size(modal_rect.min, Vec2::new(modal_w, 3.0));
painter.rect_filled(stripe_rect, CornerRadius::ZERO, accent);
// 3. Inner contents
let inner_rect = modal_rect.shrink(18.0);
let mut child_ui = ui.new_child(egui::UiBuilder::new().max_rect(inner_rect));
// Header: Scythe Icon + "Update Available" + version badge
child_ui.horizontal(|ui| {
let icon_center = egui::pos2(ui.cursor().min.x + 14.0, ui.cursor().min.y + 12.0);
draw_scythe_icon(ui.painter(), icon_center, 18.0, accent);
ui.add_space(32.0);
ui.label(
egui::RichText::new("Update Available")
.size(15.0)
.strong()
.color(Color32::WHITE),
);
ui.add_space(8.0);
let badge_text = format!("v{}", info.version);
let badge_bg = Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 35);
let badge_stroke = Stroke::new(1.0_f32, accent);
let galley = ui.painter().layout_no_wrap(
badge_text.clone(),
FontId::proportional(11.0),
accent,
);
let badge_rect = egui::Rect::from_min_size(
ui.cursor().min + egui::vec2(0.0, 3.0),
galley.size() + egui::vec2(12.0, 6.0),
);
ui.painter().rect(badge_rect, CornerRadius::ZERO, badge_bg, badge_stroke, egui::StrokeKind::Inside);
ui.painter().text(
badge_rect.center(),
egui::Align2::CENTER_CENTER,
badge_text,
FontId::proportional(11.0),
accent,
);
});
child_ui.add_space(14.0);
// Body message
child_ui.label(
egui::RichText::new(format!(
"A new version of Scythe is available (v{}). Installed: v{}.",
info.version,
crate::updater::CURRENT_VERSION
))
.size(12.0)
.color(Color32::from_rgb(220, 225, 235)),
);
child_ui.add_space(2.0);
child_ui.label(
egui::RichText::new("Would you like to open the release page to download and update now?")
.size(11.5)
.color(Color32::from_rgb(155, 160, 170)),
);
child_ui.add_space(14.0);
// Smaller toggle option: "Do not ask next time"
child_ui.checkbox(
&mut self.popup_do_not_ask,
egui::RichText::new("Do not ask next time")
.size(10.5)
.color(Color32::from_rgb(175, 180, 190)),
);
child_ui.add_space(14.0);
// Action buttons: "Update Now" and "Later"
child_ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if squared_button(ui, "Update Now", true, accent) {
if self.popup_do_not_ask {
self.config.do_not_ask_updates = true;
let _ = self.config.save();
}
self.update_dismissed = true;
crate::updater::open_browser_url(&info.html_url);
self.show_hud_notification("UPDATER", "Opening release in browser...", crate::overlay::ToastIcon::Info);
}
ui.add_space(10.0);
if squared_button(ui, "Later", false, accent) {
if self.popup_do_not_ask {
self.config.do_not_ask_updates = true;
let _ = self.config.save();
}
self.update_dismissed = true;
}
});
});
}
}
#[allow(clippy::too_many_arguments)]
fn render_scythe_notification_card(
painter: &egui::Painter,
rect: egui::Rect,
@ -3184,9 +3419,18 @@ impl eframe::App for ScytheOverlayApp {
}
}
} else {
let modal_active = {
let cur = self.update_status.lock().ok().map(|g| g.clone()).unwrap_or_default();
matches!(cur, crate::updater::UpdateStatus::Available(_))
&& !self.update_dismissed
&& !self.config.do_not_ask_updates
};
// Normal Escape handling
if !self.folder_picking_active.load(Ordering::SeqCst) && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
if self.replay_dropdown_open || self.record_dropdown_open || self.stream_dropdown_open {
if modal_active {
self.update_dismissed = true;
} else if self.replay_dropdown_open || self.record_dropdown_open || self.stream_dropdown_open {
self.replay_dropdown_open = false;
self.record_dropdown_open = false;
self.stream_dropdown_open = false;
@ -3200,7 +3444,7 @@ impl eframe::App for ScytheOverlayApp {
}
// Click outside active panel on darkened background to dismiss
if !self.folder_picking_active.load(Ordering::SeqCst) && ctx.input(|i| i.pointer.primary_clicked())
if !modal_active && !self.folder_picking_active.load(Ordering::SeqCst) && ctx.input(|i| i.pointer.primary_clicked())
&& let Some(pos) = ctx.input(|i| i.pointer.interact_pos())
&& self.panel_rect.width() > 10.0 && !self.panel_rect.expand(6.0).contains(pos) {
if self.replay_dropdown_open || self.record_dropdown_open || self.stream_dropdown_open {
@ -3243,6 +3487,7 @@ impl eframe::App for ScytheOverlayApp {
ShadowPlayView::Gallery => self.render_gallery_view(ctx, ui),
}
self.render_slide_notification(ctx, ui);
self.render_update_popup(ctx, ui);
});
}
}
@ -3671,4 +3916,23 @@ mod tests {
// Default fallback to AMD red
assert_eq!(resolve_accent_color("unknown"), Color32::from_rgb(221, 0, 49));
}
#[test]
fn test_update_popup_logic() {
let should_show_popup = |has_update: bool, dismissed: bool, do_not_ask: bool| -> bool {
has_update && !dismissed && !do_not_ask
};
// When update is available and not suppressed/dismissed -> shows popup
assert!(should_show_popup(true, false, false));
// When user dismissed the popup in current session -> does not show
assert!(!should_show_popup(true, true, false));
// When user checked 'Do not ask next time' -> suppressed
assert!(!should_show_popup(true, false, true));
// When no update is available -> does not show
assert!(!should_show_popup(false, false, false));
}
}