feat(ui): dropdown rolldown animation, sound in replay, square buttons, full QoL keybinds, and monospace font support

This commit is contained in:
katR 2026-10-08 23:09:52 +05:30
parent 6e114cb3ff
commit 20f9773080
5 changed files with 410 additions and 168 deletions

Binary file not shown.

View file

@ -350,6 +350,26 @@ fn handle_screenshot() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Ok(())
}
fn handle_mute_mic() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut cfg = scythe::config::ScytheConfig::load();
let is_muted = cfg.mic_volume < 0.05;
cfg.mic_volume = if is_muted { 0.60 } else { 0.0 };
let _ = cfg.save();
scythe::config::ScytheConfig::notify_daemon_reload();
show_shadowplay_toast("MICROPHONE", if is_muted { "Unmuted" } else { "Muted" }, ToastIcon::Info);
Ok(())
}
fn handle_mute_sys() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut cfg = scythe::config::ScytheConfig::load();
let is_muted = cfg.system_volume < 0.05;
cfg.system_volume = if is_muted { 1.00 } else { 0.0 };
let _ = cfg.save();
scythe::config::ScytheConfig::notify_daemon_reload();
show_shadowplay_toast("SYSTEM AUDIO", if is_muted { "Unmuted" } else { "Muted" }, ToastIcon::Info);
Ok(())
}
fn handle_toggle_streaming() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
ensure_daemon_running();
let cfg = scythe::config::ScytheConfig::load();
@ -409,6 +429,15 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
"--screenshot" => {
return handle_screenshot();
}
"--mute-mic" => {
return handle_mute_mic();
}
"--mute-sys" => {
return handle_mute_sys();
}
"--pause" => {
return handle_toggle_recording();
}
"--toast" => {
let title = args.get(2).map(|s| s.as_str()).unwrap_or("SCYTHE");
let subtitle = args.get(3).map(|s| s.as_str()).unwrap_or("");
@ -570,8 +599,11 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
menu: Option<u32>,
save: Option<u32>,
rec: Option<u32>,
pause: Option<u32>,
cur: Option<u32>,
shot: Option<u32>,
mute_mic: Option<u32>,
mute_sys: Option<u32>,
}
let mut current_config = scythe::config::ScytheConfig::load();
@ -585,8 +617,11 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let m_menu = scythe::hotkey::parse_hotkey(&cfg.menu_hotkey).or_else(|| Some(HotKey::new(Some(Modifiers::ALT), Code::KeyZ)));
let m_save = scythe::hotkey::parse_hotkey(&cfg.save_hotkey).or_else(|| Some(HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyR)));
let m_rec = scythe::hotkey::parse_hotkey(&cfg.record_hotkey).or_else(|| Some(HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::F9)));
let m_pause = scythe::hotkey::parse_hotkey(&cfg.pause_hotkey).or_else(|| Some(HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::F8)));
let m_cur = scythe::hotkey::parse_hotkey(&cfg.cursor_hotkey).or_else(|| Some(HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::F10)));
let m_shot = scythe::hotkey::parse_hotkey(&cfg.screenshot_hotkey).or_else(|| Some(HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyS)));
let m_mute_mic = scythe::hotkey::parse_hotkey(&cfg.mute_mic_hotkey).or_else(|| Some(HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyM)));
let m_mute_sys = scythe::hotkey::parse_hotkey(&cfg.mute_sys_hotkey).or_else(|| Some(HotKey::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyN)));
let mut ids = RegisteredIds::default();
@ -606,6 +641,11 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let _ = mgr.register(hk);
reg.push(hk);
}
if let Some(hk) = m_pause {
ids.pause = Some(hk.id());
let _ = mgr.register(hk);
reg.push(hk);
}
if let Some(hk) = m_cur {
ids.cur = Some(hk.id());
let _ = mgr.register(hk);
@ -616,13 +656,23 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let _ = mgr.register(hk);
reg.push(hk);
}
if let Some(hk) = m_mute_mic {
ids.mute_mic = Some(hk.id());
let _ = mgr.register(hk);
reg.push(hk);
}
if let Some(hk) = m_mute_sys {
ids.mute_sys = Some(hk.id());
let _ = mgr.register(hk);
reg.push(hk);
}
ids
};
let mut current_ids = register_all(&manager, &current_config, &mut registered_hotkeys);
println!("Listening for global hotkeys (Overlay: {}, Replay: {}, Record: {}, Cursor: {}, Screenshot: {})...",
current_config.menu_hotkey, current_config.save_hotkey, current_config.record_hotkey, current_config.cursor_hotkey, current_config.screenshot_hotkey);
println!("Listening for global hotkeys (Overlay: {}, Replay: {}, Record: {}, Screenshot: {})...",
current_config.menu_hotkey, current_config.save_hotkey, current_config.record_hotkey, current_config.screenshot_hotkey);
let receiver = GlobalHotKeyEvent::receiver();
let mut last_config_check = std::time::Instant::now();
@ -646,8 +696,11 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if new_cfg.menu_hotkey != current_config.menu_hotkey
|| new_cfg.save_hotkey != current_config.save_hotkey
|| new_cfg.record_hotkey != current_config.record_hotkey
|| new_cfg.pause_hotkey != current_config.pause_hotkey
|| new_cfg.cursor_hotkey != current_config.cursor_hotkey
|| new_cfg.screenshot_hotkey != current_config.screenshot_hotkey
|| new_cfg.mute_mic_hotkey != current_config.mute_mic_hotkey
|| new_cfg.mute_sys_hotkey != current_config.mute_sys_hotkey
{
current_config = new_cfg;
current_ids = register_all(&manager, &current_config, &mut registered_hotkeys);
@ -671,6 +724,10 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
std::thread::spawn(|| {
let _ = handle_toggle_recording();
});
} else if Some(event.id) == current_ids.pause {
std::thread::spawn(|| {
let _ = handle_toggle_recording();
});
} else if Some(event.id) == current_ids.cur {
std::thread::spawn(|| {
let _ = handle_toggle_cursor();
@ -679,6 +736,14 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
std::thread::spawn(|| {
let _ = handle_screenshot();
});
} else if Some(event.id) == current_ids.mute_mic {
std::thread::spawn(|| {
let _ = handle_mute_mic();
});
} else if Some(event.id) == current_ids.mute_sys {
std::thread::spawn(|| {
let _ = handle_mute_sys();
});
}
}
#[cfg(target_os = "windows")]

View file

@ -32,6 +32,11 @@ fn default_replay_storage() -> String { "ram".to_string() }
fn default_container_format() -> String { "mp4".to_string() }
fn default_res_w() -> u32 { 1920 }
fn default_res_h() -> u32 { 1080 }
fn default_screenshot_quality() -> u32 { 90 }
fn default_screenshot_preset() -> String { "balanced".to_string() }
fn default_pause_hotkey() -> String { "Ctrl+Shift+F8".to_string() }
fn default_mute_mic_hotkey() -> String { "Ctrl+Shift+M".to_string() }
fn default_mute_sys_hotkey() -> String { "Ctrl+Shift+N".to_string() }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ScytheConfig {
@ -138,6 +143,19 @@ pub struct ScytheConfig {
pub autostart_on_game: bool,
#[serde(default)]
pub autostart_power_only: bool,
#[serde(default = "default_screenshot_quality")]
pub screenshot_quality: u32,
#[serde(default = "default_screenshot_preset")]
pub screenshot_preset: String,
#[serde(default = "default_pause_hotkey")]
pub pause_hotkey: String,
#[serde(default = "default_mute_mic_hotkey")]
pub mute_mic_hotkey: String,
#[serde(default = "default_mute_sys_hotkey")]
pub mute_sys_hotkey: String,
#[serde(default)]
pub use_monospace_font: bool,
}
pub type VrecConfig = ScytheConfig;
@ -199,6 +217,13 @@ impl Default for ScytheConfig {
show_scroll_lock_led: false,
autostart_on_game: false,
autostart_power_only: false,
screenshot_quality: default_screenshot_quality(),
screenshot_preset: default_screenshot_preset(),
pause_hotkey: default_pause_hotkey(),
mute_mic_hotkey: default_mute_mic_hotkey(),
mute_sys_hotkey: default_mute_sys_hotkey(),
use_monospace_font: false,
}
}
}
@ -483,6 +508,13 @@ mod tests {
assert_eq!(parsed.screenshot_hotkey, "Ctrl+Shift+S");
assert_eq!(parsed.screenshot_format, "png");
assert!(!parsed.screenshot_cursor);
assert_eq!(parsed.screenshot_quality, 90);
assert_eq!(parsed.screenshot_preset, "balanced");
assert_eq!(parsed.pause_hotkey, "Ctrl+Shift+F8");
assert_eq!(parsed.mute_mic_hotkey, "Ctrl+Shift+M");
assert_eq!(parsed.mute_sys_hotkey, "Ctrl+Shift+N");
assert!(!parsed.use_monospace_font);
}
#[test]

View file

@ -182,8 +182,11 @@ pub fn register_hyprland_binds(config: &ScytheConfig) {
(&config.menu_hotkey, "scythe-ui --menu"),
(&config.save_hotkey, "scythe-ui --save"),
(&config.record_hotkey, "scythe-ui --record"),
(&config.pause_hotkey, "scythe-ui --pause"),
(&config.cursor_hotkey, "scythe-ui --cursor"),
(&config.screenshot_hotkey, "scythe-ui --screenshot"),
(&config.mute_mic_hotkey, "scythe-ui --mute-mic"),
(&config.mute_sys_hotkey, "scythe-ui --mute-sys"),
];
for (hotkey, cmd) in binds {

View file

@ -199,19 +199,33 @@ pub enum KeybindAction {
Menu,
SaveReplay,
ToggleRecord,
PauseRecord,
Screenshot,
ToggleCursor,
MuteMic,
MuteSystem,
}
pub fn setup_custom_fonts(ctx: &egui::Context) {
pub fn setup_custom_fonts(ctx: &egui::Context, use_monospace: bool) {
let mut fonts = egui::FontDefinitions::default();
fonts.font_data.insert(
"modern_sans".to_owned(),
Arc::new(egui::FontData::from_static(include_bytes!("../assets/fonts/AdwaitaSans-Regular.ttf"))),
);
fonts.font_data.insert(
"modern_mono".to_owned(),
Arc::new(egui::FontData::from_static(include_bytes!("../assets/fonts/AdwaitaMono-Regular.ttf"))),
);
let primary = if use_monospace { "modern_mono" } else { "modern_sans" };
fonts.families
.entry(egui::FontFamily::Proportional)
.or_default()
.insert(0, "modern_sans".to_owned());
.insert(0, primary.to_owned());
fonts.families
.entry(egui::FontFamily::Monospace)
.or_default()
.insert(0, "modern_mono".to_owned());
ctx.set_fonts(fonts);
}
@ -1147,6 +1161,9 @@ pub struct ScytheOverlayApp {
pub show_scroll_lock_led: bool,
pub autostart_on_game: bool,
pub autostart_power_only: bool,
pub screenshot_quality: u32,
pub screenshot_preset: String,
pub use_monospace_font: bool,
}
#[derive(Clone, Debug)]
@ -1238,6 +1255,9 @@ impl ScytheOverlayApp {
let show_scroll_lock_led = config.show_scroll_lock_led;
let autostart_on_game = config.autostart_on_game;
let autostart_power_only = config.autostart_power_only;
let screenshot_quality = config.screenshot_quality;
let screenshot_preset = config.screenshot_preset.clone();
let use_monospace_font = config.use_monospace_font;
let update_status = Arc::new(std::sync::Mutex::new(crate::updater::UpdateStatus::Idle));
if auto_check_updates {
@ -1317,6 +1337,9 @@ impl ScytheOverlayApp {
show_scroll_lock_led,
autostart_on_game,
autostart_power_only,
screenshot_quality,
screenshot_preset,
use_monospace_font,
}
}
@ -1366,6 +1389,9 @@ impl ScytheOverlayApp {
self.config.show_scroll_lock_led = self.show_scroll_lock_led;
self.config.autostart_on_game = self.autostart_on_game;
self.config.autostart_power_only = self.autostart_power_only;
self.config.screenshot_quality = self.screenshot_quality;
self.config.screenshot_preset = self.screenshot_preset.clone();
self.config.use_monospace_font = self.use_monospace_font;
let _ = self.config.save();
crate::hyprland_binds::register_hyprland_binds(&self.config);
crate::config::ScytheConfig::notify_daemon_reload();
@ -1409,6 +1435,9 @@ impl ScytheOverlayApp {
self.show_scroll_lock_led = def.show_scroll_lock_led;
self.autostart_on_game = def.autostart_on_game;
self.autostart_power_only = def.autostart_power_only;
self.screenshot_quality = def.screenshot_quality;
self.screenshot_preset = def.screenshot_preset;
self.use_monospace_font = def.use_monospace_font;
self.show_hud_notification("DEFAULTS", "Settings restored to defaults", crate::overlay::ToastIcon::Info);
}
@ -1834,138 +1863,171 @@ impl ScytheOverlayApp {
self.switch_view(ShadowPlayView::Settings(SettingsTab::General), ctx);
}
// Dropdown menus floating beneath cards with sleek rounded frosted glass
let item_h = 38.0_f32;
// Dropdown menus with rolldown/swipe animation and smooth background blur
let item_h = 36.0_f32;
let anim_replay = ui.ctx().animate_bool(ui.make_persistent_id("anim_replay_dropdown"), self.replay_dropdown_open);
let anim_record = ui.ctx().animate_bool(ui.make_persistent_id("anim_record_dropdown"), self.record_dropdown_open);
let anim_stream = ui.ctx().animate_bool(ui.make_persistent_id("anim_stream_dropdown"), self.stream_dropdown_open);
if anim_replay > 0.001 || anim_record > 0.001 || anim_stream > 0.001 {
ctx.request_repaint();
}
// Instant Replay dropdown
if self.replay_dropdown_open {
let drop_y = card0_rect.bottom() + 6.0;
let total_h = item_h * 5.0;
let frame_rect = egui::Rect::from_min_size(egui::pos2(card0_rect.left(), drop_y), Vec2::new(card_w, total_h));
if anim_replay > 0.001 {
let ease = 1.0 - (1.0 - anim_replay).powi(3);
let drop_y = card0_rect.bottom() + 4.0;
let full_h = item_h * 5.0;
let cur_h = full_h * ease;
let frame_rect = egui::Rect::from_min_size(egui::pos2(card0_rect.left(), drop_y), Vec2::new(card_w, cur_h));
draw_dropdown_frame(ui.painter(), frame_rect, accent);
let r0 = egui::Rect::from_min_size(egui::pos2(card0_rect.left(), drop_y), Vec2::new(card_w, item_h));
let r1 = egui::Rect::from_min_size(egui::pos2(card0_rect.left(), drop_y + item_h), Vec2::new(card_w, item_h));
let r2 = egui::Rect::from_min_size(egui::pos2(card0_rect.left(), drop_y + item_h * 2.0), Vec2::new(card_w, item_h));
let r3 = egui::Rect::from_min_size(egui::pos2(card0_rect.left(), drop_y + item_h * 3.0), Vec2::new(card_w, item_h));
let r4 = egui::Rect::from_min_size(egui::pos2(card0_rect.left(), drop_y + item_h * 4.0), Vec2::new(card_w, item_h));
let clip_rect = frame_rect;
ui.allocate_new_ui(egui::UiBuilder::new().max_rect(clip_rect), |ui| {
ui.set_clip_rect(clip_rect);
let slide_offset = (1.0 - ease) * -12.0;
let toggle_title = if is_replay_active { "Turn off" } else { "Turn on" };
let toggle_icon = if is_replay_active { tex_stop } else { tex_play };
if render_dropdown_item(ui, r0, toggle_title, None, toggle_icon, accent, false) {
let mut cfg = ScytheConfig::load();
cfg.replay_enabled = !cfg.replay_enabled;
let _ = cfg.save();
ScytheConfig::notify_daemon_reload();
self.config.replay_enabled = cfg.replay_enabled;
self.status.is_replay_active = cfg.replay_enabled;
self.replay_dropdown_open = false;
}
let r0 = egui::Rect::from_min_size(egui::pos2(card0_rect.left(), drop_y + slide_offset), Vec2::new(card_w, item_h));
let r1 = egui::Rect::from_min_size(egui::pos2(card0_rect.left(), drop_y + item_h + slide_offset), Vec2::new(card_w, item_h));
let r2 = egui::Rect::from_min_size(egui::pos2(card0_rect.left(), drop_y + item_h * 2.0 + slide_offset), Vec2::new(card_w, item_h));
let r3 = egui::Rect::from_min_size(egui::pos2(card0_rect.left(), drop_y + item_h * 3.0 + slide_offset), Vec2::new(card_w, item_h));
let r4 = egui::Rect::from_min_size(egui::pos2(card0_rect.left(), drop_y + item_h * 4.0 + slide_offset), Vec2::new(card_w, item_h));
let save_hk = self.config.save_hotkey.trim();
let save_hk_opt = if save_hk.is_empty() { None } else { Some(save_hk) };
if render_dropdown_item(ui, r1, "Save Replay", save_hk_opt, tex_save, accent, false) {
if is_replay_active {
async_send_command(Command::SaveReplay);
self.show_hud_notification("INSTANT REPLAY", "Saved to Videos", crate::overlay::ToastIcon::Replay);
} else {
self.show_hud_notification("INSTANT REPLAY", "Replay is turned off", crate::overlay::ToastIcon::Error);
let toggle_title = if is_replay_active { "Turn off" } else { "Turn on" };
let toggle_icon = if is_replay_active { tex_stop } else { tex_play };
if render_dropdown_item(ui, r0, toggle_title, None, toggle_icon, accent, false) {
let mut cfg = ScytheConfig::load();
cfg.replay_enabled = !cfg.replay_enabled;
let _ = cfg.save();
ScytheConfig::notify_daemon_reload();
self.config.replay_enabled = cfg.replay_enabled;
self.status.is_replay_active = cfg.replay_enabled;
self.replay_dropdown_open = false;
}
self.replay_dropdown_open = false;
}
if render_dropdown_item(ui, r2, "Save 1 min", None, tex_save, accent, false) {
if is_replay_active {
async_send_command(Command::SaveReplay);
self.show_hud_notification("INSTANT REPLAY", "Saved 1 min replay", crate::overlay::ToastIcon::Replay);
} else {
self.show_hud_notification("INSTANT REPLAY", "Replay is turned off", crate::overlay::ToastIcon::Error);
let save_hk = self.config.save_hotkey.trim();
let save_hk_opt = if save_hk.is_empty() { None } else { Some(save_hk) };
if render_dropdown_item(ui, r1, "Save Replay", save_hk_opt, tex_save, accent, false) {
if is_replay_active {
async_send_command(Command::SaveReplay);
self.show_hud_notification("INSTANT REPLAY", "Saved to Videos", crate::overlay::ToastIcon::Replay);
} else {
self.show_hud_notification("INSTANT REPLAY", "Replay is turned off", crate::overlay::ToastIcon::Error);
}
self.replay_dropdown_open = false;
}
self.replay_dropdown_open = false;
}
if render_dropdown_item(ui, r3, "Save 10 min", None, tex_save, accent, false) {
if is_replay_active {
async_send_command(Command::SaveReplay);
self.show_hud_notification("INSTANT REPLAY", "Saved 10 min replay", crate::overlay::ToastIcon::Replay);
} else {
self.show_hud_notification("INSTANT REPLAY", "Replay is turned off", crate::overlay::ToastIcon::Error);
if render_dropdown_item(ui, r2, "Save 1 min", None, tex_save, accent, false) {
if is_replay_active {
async_send_command(Command::SaveReplay);
self.show_hud_notification("INSTANT REPLAY", "Saved 1 min replay", crate::overlay::ToastIcon::Replay);
} else {
self.show_hud_notification("INSTANT REPLAY", "Replay is turned off", crate::overlay::ToastIcon::Error);
}
self.replay_dropdown_open = false;
}
self.replay_dropdown_open = false;
}
if render_dropdown_item(ui, r4, "Settings", None, tex_settings_xs, accent, true) {
self.switch_view(ShadowPlayView::Settings(SettingsTab::Replay), ctx);
}
if render_dropdown_item(ui, r3, "Save 10 min", None, tex_save, accent, false) {
if is_replay_active {
async_send_command(Command::SaveReplay);
self.show_hud_notification("INSTANT REPLAY", "Saved 10 min replay", crate::overlay::ToastIcon::Replay);
} else {
self.show_hud_notification("INSTANT REPLAY", "Replay is turned off", crate::overlay::ToastIcon::Error);
}
self.replay_dropdown_open = false;
}
if render_dropdown_item(ui, r4, "Settings", None, tex_settings_xs, accent, true) {
self.switch_view(ShadowPlayView::Settings(SettingsTab::Replay), ctx);
}
});
}
// Record dropdown
if self.record_dropdown_open {
let drop_y = card1_rect.bottom() + 6.0;
let total_h = item_h * 3.0;
let frame_rect = egui::Rect::from_min_size(egui::pos2(card1_rect.left(), drop_y), Vec2::new(card_w, total_h));
if anim_record > 0.001 {
let ease = 1.0 - (1.0 - anim_record).powi(3);
let drop_y = card1_rect.bottom() + 4.0;
let full_h = item_h * 3.0;
let cur_h = full_h * ease;
let frame_rect = egui::Rect::from_min_size(egui::pos2(card1_rect.left(), drop_y), Vec2::new(card_w, cur_h));
draw_dropdown_frame(ui.painter(), frame_rect, accent);
let r0 = egui::Rect::from_min_size(egui::pos2(card1_rect.left(), drop_y), Vec2::new(card_w, item_h));
let r1 = egui::Rect::from_min_size(egui::pos2(card1_rect.left(), drop_y + item_h), Vec2::new(card_w, item_h));
let r2 = egui::Rect::from_min_size(egui::pos2(card1_rect.left(), drop_y + item_h * 2.0), Vec2::new(card_w, item_h));
let clip_rect = frame_rect;
ui.allocate_new_ui(egui::UiBuilder::new().max_rect(clip_rect), |ui| {
ui.set_clip_rect(clip_rect);
let slide_offset = (1.0 - ease) * -12.0;
let rec_toggle_title = if is_recording { "Stop Recording" } else { "Start Recording" };
let rec_toggle_icon = if is_recording { tex_stop } else { tex_play };
let rec_hk = self.config.record_hotkey.trim();
let rec_hk_opt = if rec_hk.is_empty() { None } else { Some(rec_hk) };
if render_dropdown_item(ui, r0, rec_toggle_title, rec_hk_opt, rec_toggle_icon, accent, false) {
async_send_command(Command::ToggleRecording);
if is_recording {
self.show_hud_notification("RECORDING", "Recording saved", crate::overlay::ToastIcon::Save);
} else {
self.show_hud_notification("RECORDING", "Recording started", crate::overlay::ToastIcon::Record);
let r0 = egui::Rect::from_min_size(egui::pos2(card1_rect.left(), drop_y + slide_offset), Vec2::new(card_w, item_h));
let r1 = egui::Rect::from_min_size(egui::pos2(card1_rect.left(), drop_y + item_h + slide_offset), Vec2::new(card_w, item_h));
let r2 = egui::Rect::from_min_size(egui::pos2(card1_rect.left(), drop_y + item_h * 2.0 + slide_offset), Vec2::new(card_w, item_h));
let rec_toggle_title = if is_recording { "Stop Recording" } else { "Start Recording" };
let rec_toggle_icon = if is_recording { tex_stop } else { tex_play };
let rec_hk = self.config.record_hotkey.trim();
let rec_hk_opt = if rec_hk.is_empty() { None } else { Some(rec_hk) };
if render_dropdown_item(ui, r0, rec_toggle_title, rec_hk_opt, rec_toggle_icon, accent, false) {
async_send_command(Command::ToggleRecording);
if is_recording {
self.show_hud_notification("RECORDING", "Recording saved", crate::overlay::ToastIcon::Save);
} else {
self.show_hud_notification("RECORDING", "Recording started", crate::overlay::ToastIcon::Record);
}
self.record_dropdown_open = false;
}
self.record_dropdown_open = false;
}
let pause_hk_opt: Option<&str> = None;
if render_dropdown_item(ui, r1, "Pause", pause_hk_opt, tex_pause, accent, false) {
self.record_dropdown_open = false;
}
let pause_hk = self.config.pause_hotkey.trim();
let pause_hk_opt = if pause_hk.is_empty() { None } else { Some(pause_hk) };
if render_dropdown_item(ui, r1, "Pause", pause_hk_opt, tex_pause, accent, false) {
self.record_dropdown_open = false;
}
if render_dropdown_item(ui, r2, "Settings", None, tex_settings_xs, accent, true) {
self.switch_view(ShadowPlayView::Settings(SettingsTab::Record), ctx);
}
if render_dropdown_item(ui, r2, "Settings", None, tex_settings_xs, accent, true) {
self.switch_view(ShadowPlayView::Settings(SettingsTab::Record), ctx);
}
});
}
// Livestream dropdown
if self.stream_dropdown_open {
let drop_y = card2_rect.bottom() + 6.0;
let total_h = item_h * 2.0;
let frame_rect = egui::Rect::from_min_size(egui::pos2(card2_rect.left(), drop_y), Vec2::new(card_w, total_h));
if anim_stream > 0.001 {
let ease = 1.0 - (1.0 - anim_stream).powi(3);
let drop_y = card2_rect.bottom() + 4.0;
let full_h = item_h * 2.0;
let cur_h = full_h * ease;
let frame_rect = egui::Rect::from_min_size(egui::pos2(card2_rect.left(), drop_y), Vec2::new(card_w, cur_h));
draw_dropdown_frame(ui.painter(), frame_rect, accent);
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));
let clip_rect = frame_rect;
ui.allocate_new_ui(egui::UiBuilder::new().max_rect(clip_rect), |ui| {
ui.set_clip_rect(clip_rect);
let slide_offset = (1.0 - ease) * -12.0;
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);
let r0 = egui::Rect::from_min_size(egui::pos2(card2_rect.left(), drop_y + slide_offset), Vec2::new(card_w, item_h));
let r1 = egui::Rect::from_min_size(egui::pos2(card2_rect.left(), drop_y + item_h + slide_offset), Vec2::new(card_w, item_h));
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;
}
self.stream_dropdown_open = false;
}
if render_dropdown_item(ui, r1, "Settings", None, tex_settings_xs, accent, true) {
self.switch_view(ShadowPlayView::Settings(SettingsTab::Livestream), ctx);
}
if render_dropdown_item(ui, r1, "Settings", None, tex_settings_xs, accent, true) {
self.switch_view(ShadowPlayView::Settings(SettingsTab::Livestream), ctx);
}
});
}
}
fn render_settings_view(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
let screen_w = ui.available_width();
let screen_h = ui.available_height();
@ -2281,6 +2343,49 @@ impl ScytheOverlayApp {
ui.add_space(10.0);
render_section_card(ui, "Audio", accent, |ui| {
ui.label(egui::RichText::new("Audio capture source mode:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
ui.add_space(4.0);
ui.horizontal(|ui| {
let modes = [("system", "System Audio"), ("mic", "Microphone"), ("both", "Both"), ("muted", "Muted")];
for (idx, (_, m_label)) in modes.iter().enumerate() {
if squared_button(ui, m_label, self.audio_mode_idx == idx, accent) {
self.audio_mode_idx = idx;
}
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Audio codec:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
if squared_button(ui, "Opus (Recommended)", self.config.audio_codec == "opus", accent) {
self.config.audio_codec = "opus".to_string();
}
if squared_button(ui, "AAC", self.config.audio_codec == "aac", accent) {
self.config.audio_codec = "aac".to_string();
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Sys Vol:").size(11.5).color(Color32::WHITE));
let mut sv = self.system_volume_pct;
if ui.add(egui::Slider::new(&mut sv, 0..=150).suffix("%")).changed() {
self.system_volume_pct = sv;
}
ui.add_space(10.0);
ui.label(egui::RichText::new("Mic Vol:").size(11.5).color(Color32::WHITE));
let mut mv = self.mic_volume_pct;
if ui.add(egui::Slider::new(&mut mv, 0..=150).suffix("%")).changed() {
self.mic_volume_pct = mv;
}
});
ui.add_space(3.0);
render_vu_meter(ui, self.sys_vu, ui.available_width() - 8.0, 10.0, "SYS");
});
ui.add_space(10.0);
render_section_card(ui, "Video parameters", accent, |ui| {
ui.horizontal(|ui| {
ui.vertical(|ui| {
@ -2343,21 +2448,22 @@ impl ScytheOverlayApp {
ui.add_space(10.0);
render_section_card(ui, "Autostart", accent, |ui| {
ui.horizontal(|ui| {
let neither = !self.autostart_replay && !self.autostart_on_game;
if squared_button(ui, "Don't turn on replay automatically", neither, accent) {
self.autostart_replay = false;
self.autostart_on_game = false;
}
if squared_button(ui, "Turn on replay when this program starts", self.autostart_replay, accent) {
self.autostart_replay = true;
self.autostart_on_game = false;
}
if squared_button(ui, "Turn on replay when starting a game", self.autostart_on_game, accent) {
self.autostart_on_game = true;
self.autostart_replay = false;
}
});
let neither = !self.autostart_replay && !self.autostart_on_game;
let btn_w = ui.available_width() - 8.0;
if squared_button_sized(ui, "Don't turn on replay automatically", neither, accent, Some(Vec2::new(btn_w, 26.0))) {
self.autostart_replay = false;
self.autostart_on_game = false;
}
ui.add_space(3.0);
if squared_button_sized(ui, "Turn on replay when this program starts", self.autostart_replay, accent, Some(Vec2::new(btn_w, 26.0))) {
self.autostart_replay = true;
self.autostart_on_game = false;
}
ui.add_space(3.0);
if squared_button_sized(ui, "Turn on replay when starting a game", self.autostart_on_game, accent, Some(Vec2::new(btn_w, 26.0))) {
self.autostart_on_game = true;
self.autostart_replay = false;
}
ui.add_space(6.0);
ui.horizontal(|ui| {
let mut pwr_only = self.autostart_power_only;
@ -2417,45 +2523,16 @@ impl ScytheOverlayApp {
ui.add_space(10.0);
render_section_card(ui, "Audio", accent, |ui| {
ui.label(egui::RichText::new("Audio capture source mode:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
ui.add_space(4.0);
ui.horizontal(|ui| {
let _ = squared_button(ui, "Add audio track", false, accent);
let modes = [("system", "System Audio"), ("mic", "Microphone"), ("both", "Both"), ("muted", "Muted")];
for (idx, (_, m_label)) in modes.iter().enumerate() {
if squared_button(ui, m_label, self.audio_mode_idx == idx, accent) {
self.audio_mode_idx = idx;
}
}
});
ui.add_space(8.0);
// Inset Audio Track #1 Box
egui::Frame::NONE
.fill(Color32::from_rgb(20, 24, 30))
.stroke(Stroke::new(1.0_f32, Color32::from_rgb(34, 40, 50)))
.corner_radius(CornerRadius::ZERO)
.inner_margin(Margin::same(10_i8))
.show(ui, |ui| {
ui.label(egui::RichText::new("Audio track #1").size(12.0).strong().color(Color32::WHITE));
ui.add_space(6.0);
ui.horizontal(|ui| {
let _ = squared_button(ui, "Add output device", false, accent);
let _ = squared_button(ui, "Add input device", false, accent);
let _ = squared_button(ui, "Add application audio", false, accent);
});
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Output device: Default output").size(11.5).color(Color32::WHITE));
});
ui.add_space(8.0);
ui.horizontal(|ui| {
let mut rec_all = true;
let _ = toggle_switch(ui, &mut rec_all, accent);
ui.label(egui::RichText::new("Record audio from all applications except the selected ones").size(11.0).color(Color32::from_rgb(200, 205, 215)));
});
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Track title:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
let mut track_t = String::new();
ui.add(egui::TextEdit::singleline(&mut track_t).desired_width(180.0));
});
});
ui.add_space(8.0);
ui.horizontal(|ui| {
@ -2575,12 +2652,6 @@ impl ScytheOverlayApp {
ui.horizontal(|ui| {
let _ = squared_button(ui, "Focused monitor", true, accent);
});
ui.add_space(6.0);
ui.horizontal(|ui| {
let mut chg_res = false;
let _ = toggle_switch(ui, &mut chg_res, accent);
ui.label(egui::RichText::new("Change video resolution").size(11.5).color(Color32::WHITE));
});
});
ui.add_space(10.0);
@ -2661,6 +2732,25 @@ impl ScytheOverlayApp {
}
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Compression quality:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
let mut q = self.screenshot_quality;
if ui.add(egui::Slider::new(&mut q, 10..=100).suffix("%")).changed() {
self.screenshot_quality = q;
}
});
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Compression preset:").size(11.5).color(Color32::from_rgb(180, 185, 190)));
for (p_id, label) in [("fast", "Fast"), ("balanced", "Balanced"), ("max", "Maximum Compression")] {
if squared_button(ui, label, self.screenshot_preset == p_id, accent) {
self.screenshot_preset = p_id.to_string();
}
}
});
});
ui.add_space(10.0);
@ -2791,7 +2881,11 @@ impl ScytheOverlayApp {
(KeybindAction::Menu, "Menu Overlay", &self.config.menu_hotkey),
(KeybindAction::SaveReplay, "Save Instant Replay", &self.config.save_hotkey),
(KeybindAction::ToggleRecord, "Start / Stop Record", &self.config.record_hotkey),
(KeybindAction::PauseRecord, "Pause / Resume Record", &self.config.pause_hotkey),
(KeybindAction::Screenshot, "Take Screenshot", &self.config.screenshot_hotkey),
(KeybindAction::ToggleCursor, "Toggle Mouse Cursor", &self.config.cursor_hotkey),
(KeybindAction::MuteMic, "Mute / Unmute Microphone", &self.config.mute_mic_hotkey),
(KeybindAction::MuteSystem, "Mute / Unmute System Audio", &self.config.mute_sys_hotkey),
];
for (action, label, current_hotkey) in binds {
@ -2813,7 +2907,7 @@ impl ScytheOverlayApp {
}
});
});
ui.add_space(6.0);
ui.add_space(5.0);
}
if self.listening_keybind.is_some() {
@ -2828,19 +2922,31 @@ impl ScytheOverlayApp {
ui.add_space(6.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if squared_button_sized(ui, "Reset to Defaults", false, accent, Some(Vec2::new(130.0, 26.0))) {
if squared_button_sized(ui, "Reset Hotkeys", false, accent, Some(Vec2::new(96.0, 24.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();
let old_pause = self.config.pause_hotkey.clone();
let old_cur = self.config.cursor_hotkey.clone();
let old_shot = self.config.screenshot_hotkey.clone();
let old_mic = self.config.mute_mic_hotkey.clone();
let old_sys = self.config.mute_sys_hotkey.clone();
crate::hyprland_binds::unbind_hotkey_async(&old_menu);
crate::hyprland_binds::unbind_hotkey_async(&old_save);
crate::hyprland_binds::unbind_hotkey_async(&old_rec);
crate::hyprland_binds::unbind_hotkey_async(&old_pause);
crate::hyprland_binds::unbind_hotkey_async(&old_cur);
crate::hyprland_binds::unbind_hotkey_async(&old_shot);
crate::hyprland_binds::unbind_hotkey_async(&old_mic);
crate::hyprland_binds::unbind_hotkey_async(&old_sys);
self.config.menu_hotkey = "Alt+Z".to_string();
self.config.save_hotkey = "Ctrl+Shift+R".to_string();
self.config.record_hotkey = "Ctrl+Shift+F9".to_string();
self.config.pause_hotkey = "Ctrl+Shift+F8".to_string();
self.config.screenshot_hotkey = "Ctrl+Shift+S".to_string();
self.config.cursor_hotkey = "Ctrl+Shift+F10".to_string();
self.config.mute_mic_hotkey = "Ctrl+Shift+M".to_string();
self.config.mute_sys_hotkey = "Ctrl+Shift+N".to_string();
let _ = self.config.save();
crate::hyprland_binds::register_hyprland_binds_async(&self.config);
crate::config::ScytheConfig::notify_daemon_reload();
@ -2851,6 +2957,21 @@ impl ScytheOverlayApp {
ui.add_space(10.0);
render_section_card(ui, "Interface Typography", accent, |ui| {
ui.horizontal(|ui| {
let mut mono = self.use_monospace_font;
if toggle_switch(ui, &mut mono, accent).changed() {
self.use_monospace_font = mono;
self.config.use_monospace_font = mono;
let _ = self.config.save();
setup_custom_fonts(ui.ctx(), mono);
}
ui.label(egui::RichText::new("Use monospace font across interface (Adwaita Mono)").size(11.5).color(Color32::WHITE));
});
});
ui.add_space(10.0);
render_section_card(ui, "Startup Integration", accent, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Start replay engine on login").size(12.0).color(Color32::WHITE));
@ -3684,6 +3805,26 @@ impl eframe::App for ScytheOverlayApp {
self.config.cursor_hotkey = combo.clone();
("Cursor Toggle", old)
}
KeybindAction::PauseRecord => {
let old = self.config.pause_hotkey.clone();
self.config.pause_hotkey = combo.clone();
("Pause Toggle", old)
}
KeybindAction::Screenshot => {
let old = self.config.screenshot_hotkey.clone();
self.config.screenshot_hotkey = combo.clone();
("Screenshot", old)
}
KeybindAction::MuteMic => {
let old = self.config.mute_mic_hotkey.clone();
self.config.mute_mic_hotkey = combo.clone();
("Mute Microphone", old)
}
KeybindAction::MuteSystem => {
let old = self.config.mute_sys_hotkey.clone();
self.config.mute_sys_hotkey = combo.clone();
("Mute System", old)
}
};
crate::hyprland_binds::unbind_hotkey_async(&old_key);
let _ = self.config.save();
@ -3930,7 +4071,8 @@ pub fn run_egui_overlay() {
"scythe-overlay",
options,
Box::new(|cc| {
setup_custom_fonts(&cc.egui_ctx);
let cfg = ScytheConfig::load();
setup_custom_fonts(&cc.egui_ctx, cfg.use_monospace_font);
Ok(Box::new(ScytheOverlayApp::new()))
}),
);
@ -4111,8 +4253,8 @@ pub fn run_egui_toast(title: &str, subtitle: &str, icon: crate::overlay::ToastIc
let _ = eframe::run_native(
"scythe-toast",
options,
Box::new(|cc| {
setup_custom_fonts(&cc.egui_ctx);
Box::new(move |cc| {
setup_custom_fonts(&cc.egui_ctx, cfg.use_monospace_font);
Ok(Box::new(app))
}),
);