diff --git a/dash-frontend/src/tab/settings/mod.rs b/dash-frontend/src/tab/settings/mod.rs index bcd79abf..8e665e4f 100644 --- a/dash-frontend/src/tab/settings/mod.rs +++ b/dash-frontend/src/tab/settings/mod.rs @@ -223,6 +223,13 @@ enum SettingType { ClickFreezeTimeMs, Clock12h, DoubleCursorFix, + FocusedScreenAssistX, + FocusedScreenAssistY, + FocusedScreenRotateAssistX, + FocusedScreenRotateAssistY, + FocusedScreenCurveX, + FocusedScreenDistance, + FocusedScreenScale, FocusFollowsMouseMode, HandsfreePointer, HideGrabHelp, @@ -290,6 +297,13 @@ impl SettingType { pub fn mut_f32(self, config: &mut GeneralConfig) -> &mut f32 { match self { Self::UiAnimationSpeed => &mut config.ui_animation_speed, + Self::FocusedScreenAssistX => &mut config.focused_screen_assist_x, + Self::FocusedScreenAssistY => &mut config.focused_screen_assist_y, + Self::FocusedScreenRotateAssistX => &mut config.focused_screen_rotate_assist_x, + Self::FocusedScreenRotateAssistY => &mut config.focused_screen_rotate_assist_y, + Self::FocusedScreenCurveX => &mut config.focused_screen_curve_x, + Self::FocusedScreenDistance => &mut config.focused_screen_distance, + Self::FocusedScreenScale => &mut config.focused_screen_scale, Self::UiGradientIntensity => &mut config.ui_gradient_intensity, Self::UiRoundMultiplier => &mut config.ui_round_multiplier, Self::ScrollSpeed => &mut config.scroll_speed, @@ -372,6 +386,13 @@ impl SettingType { Self::ClickFreezeTimeMs => Ok("APP_SETTINGS.CLICK_FREEZE_TIME_MS"), Self::Clock12h => Ok("APP_SETTINGS.CLOCK_12H"), Self::DoubleCursorFix => Ok("APP_SETTINGS.DOUBLE_CURSOR_FIX"), + Self::FocusedScreenAssistX => Err("Focused screen assist X"), + Self::FocusedScreenAssistY => Err("Focused screen assist Y"), + Self::FocusedScreenRotateAssistX => Err("Focused screen rotate assist X"), + Self::FocusedScreenRotateAssistY => Err("Focused screen rotate assist Y"), + Self::FocusedScreenCurveX => Err("Focused screen curve X"), + Self::FocusedScreenDistance => Err("Focused screen distance"), + Self::FocusedScreenScale => Err("Focused screen scale"), Self::FocusFollowsMouseMode => Ok("APP_SETTINGS.FOCUS_FOLLOWS_MOUSE_MODE"), Self::GridOpacity => Ok("APP_SETTINGS.GRID_OPACITY"), Self::HandsfreePointer => Ok("APP_SETTINGS.HANDSFREE_POINTER"), diff --git a/dash-frontend/src/tab/settings/tab_controls.rs b/dash-frontend/src/tab/settings/tab_controls.rs index 27b1f10b..a4d0940e 100644 --- a/dash-frontend/src/tab/settings/tab_controls.rs +++ b/dash-frontend/src/tab/settings/tab_controls.rs @@ -16,6 +16,13 @@ pub fn mount(mp: &mut MacroParams, parent: WidgetID) -> anyhow::Result<()> { options_slider_f32(mp, c, SettingType::ScrollSpeed, 0.1, 5.0, 0.1)?; options_slider_f32(mp, c, SettingType::LongPressDuration, 0.1, 2.0, 0.1)?; options_slider_f32(mp, c, SettingType::PointerLerpFactor, 0.1, 1.0, 0.1)?; + options_slider_f32(mp, c, SettingType::FocusedScreenDistance, 0.2, 2.0, 0.05)?; + options_slider_f32(mp, c, SettingType::FocusedScreenScale, 1.0, 2.5, 0.05)?; + options_slider_f32(mp, c, SettingType::FocusedScreenCurveX, 0.0, 0.8, 0.02)?; + options_slider_f32(mp, c, SettingType::FocusedScreenAssistX, 0.0, 0.75, 0.01)?; + options_slider_f32(mp, c, SettingType::FocusedScreenAssistY, 0.0, 0.85, 0.01)?; + options_slider_f32(mp, c, SettingType::FocusedScreenRotateAssistX, 0.0, 0.5, 0.01)?; + options_slider_f32(mp, c, SettingType::FocusedScreenRotateAssistY, 0.0, 0.5, 0.01)?; options_slider_f32(mp, c, SettingType::XrClickSensitivity, 0.1, 1.0, 0.1)?; options_slider_f32(mp, c, SettingType::XrClickSensitivityRelease, 0.1, 1.0, 0.1)?; options_slider_i32(mp, c, SettingType::ClickFreezeTimeMs, 0, 500, 50)?; diff --git a/scripts/test-focus.sh b/scripts/test-focus.sh new file mode 100755 index 00000000..bc4a1e40 --- /dev/null +++ b/scripts/test-focus.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -e + +REPO_ROOT="/home/taylor/dev/open-source/real-wayvr" +WAYVRCTL="$REPO_ROOT/target/debug/wayvrctl" + +if [ ! -f "$WAYVRCTL" ]; then + echo "wayvrctl not found, building..." + cd "$REPO_ROOT" + cargo build --package wayvrctl +fi + +active_window=$(hyprctl -j activewindow) + +if [ -z "$active_window" ] || [ "$active_window" = "{}" ]; then + echo "No active window found" + exit 1 +fi + +monitor_id=$(echo "$active_window" | jq -r '.monitor') +monitor_name=$(hyprctl -j monitors | jq -r --argjson monitor_id "$monitor_id" '.[] | select(.id == $monitor_id) | .name' | head -n 1) +monitor_json=$(hyprctl -j monitors | jq -c --argjson monitor_id "$monitor_id" '.[] | select(.id == $monitor_id)' | head -n 1) + +if [ -z "$monitor_name" ] || [ "$monitor_name" = "null" ]; then + echo "Could not resolve active monitor" + exit 1 +fi + +target_x=$(jq -n \ + --argjson active "$active_window" \ + --argjson monitor "$monitor_json" \ + '(((($active.at[0] + ($active.size[0] / 2)) - $monitor.x) / $monitor.width) | if . < 0 then 0 elif . > 1 then 1 else . end)') + +target_y=$(jq -n \ + --argjson active "$active_window" \ + --argjson monitor "$monitor_json" \ + '(((($active.at[1] + ($active.size[1] / 2)) - $monitor.y) / $monitor.height) | if . < 0 then 0 elif . > 1 then 1 else . end)') + +echo "Current wayvrctl screen focus commands:" +"$WAYVRCTL" screen-focus-toggle --help +echo +"$WAYVRCTL" screen-focus-at --help +echo +echo "Toggling screen focus for active monitor: $monitor_name" +"$WAYVRCTL" screen-focus-toggle "$monitor_name" +echo +echo "Refreshing screen focus at target: monitor=$monitor_name x=$target_x y=$target_y" +"$WAYVRCTL" screen-focus-at --refresh-only "$monitor_name" "$target_x" "$target_y" diff --git a/scripts/wayvr-hypr-focus.sh b/scripts/wayvr-hypr-focus.sh new file mode 100755 index 00000000..d9129c7d --- /dev/null +++ b/scripts/wayvr-hypr-focus.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -e + +REPO_ROOT="/home/taylor/dev/open-source/real-wayvr" +WAYVRCTL="$REPO_ROOT/target/debug/wayvrctl" + +if [ ! -f "$WAYVRCTL" ]; then + echo "wayvrctl not found, building..." + cd "$REPO_ROOT" + cargo build --package wayvrctl +fi + +active_window=$(hyprctl -j activewindow) + +if [ -z "$active_window" ] || [ "$active_window" = "{}" ]; then + echo "No active window found in Hyprland" + exit 1 +fi + +monitor_id=$(echo "$active_window" | jq -r '.monitor') + +if [ -z "$monitor_id" ] || [ "$monitor_id" = "null" ]; then + echo "Could not get active monitor id" + exit 1 +fi + +monitor_name=$(hyprctl -j monitors | jq -r --argjson monitor_id "$monitor_id" '.[] | select(.id == $monitor_id) | .name' | head -n 1) + +if [ -z "$monitor_name" ] || [ "$monitor_name" = "null" ]; then + echo "Could not resolve monitor name for id $monitor_id" + exit 1 +fi + +echo "Toggling focused screen for active monitor: $monitor_name" +exec "$WAYVRCTL" screen-focus-toggle "$monitor_name" diff --git a/scripts/wayvr-hypr-helper-local.sh b/scripts/wayvr-hypr-helper-local.sh new file mode 100755 index 00000000..74294223 --- /dev/null +++ b/scripts/wayvr-hypr-helper-local.sh @@ -0,0 +1,142 @@ +#!/bin/bash +set -e + +WAYVRCTL="${WAYVRCTL:-wayvrctl}" +STATE_DIR="/tmp/wayvr-hypr-focus" +PID_FILE="$STATE_DIR/watch.pid" +SCREEN_FILE="$STATE_DIR/screen_name" +SOCKET_PATH="${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.socket2.sock" + +mkdir -p "$STATE_DIR" + +run_focus_command() { + local refresh_flag="$1" + local expected_screen="${2:-}" + + active_window=$(hyprctl -j activewindow) + + if [ -z "$active_window" ] || [ "$active_window" = "{}" ]; then + return 1 + fi + + monitor_id=$(echo "$active_window" | jq -r '.monitor') + + if [ -z "$monitor_id" ] || [ "$monitor_id" = "null" ]; then + return 1 + fi + + monitor_name=$(hyprctl -j monitors | jq -r --argjson monitor_id "$monitor_id" '.[] | select(.id == $monitor_id) | .name' | head -n 1) + monitor_json=$(hyprctl -j monitors | jq -c --argjson monitor_id "$monitor_id" '.[] | select(.id == $monitor_id)' | head -n 1) + + if [ -z "$monitor_name" ] || [ "$monitor_name" = "null" ]; then + return 1 + fi + + if [ -n "$expected_screen" ] && [ "$monitor_name" != "$expected_screen" ]; then + return 1 + fi + + target_x=$(jq -n \ + --argjson active "$active_window" \ + --argjson monitor "$monitor_json" \ + '(((($active.at[0] + ($active.size[0] / 2)) - $monitor.x) / $monitor.width) | if . < 0 then 0 elif . > 1 then 1 else . end)') + + target_y=$(jq -n \ + --argjson active "$active_window" \ + --argjson monitor "$monitor_json" \ + '(((($active.at[1] + ($active.size[1] / 2)) - $monitor.y) / $monitor.height) | if . < 0 then 0 elif . > 1 then 1 else . end)') + + crop_x=$(jq -n \ + --argjson active "$active_window" \ + --argjson monitor "$monitor_json" \ + '((($active.at[0] - $monitor.x) / $monitor.width) | if . < 0 then 0 elif . > 1 then 1 else . end)') + + crop_y=$(jq -n \ + --argjson active "$active_window" \ + --argjson monitor "$monitor_json" \ + '((($active.at[1] - $monitor.y) / $monitor.height) | if . < 0 then 0 elif . > 1 then 1 else . end)') + + crop_w=$(jq -n \ + --argjson active "$active_window" \ + --argjson monitor "$monitor_json" \ + '((($active.size[0]) / $monitor.width) | if . < 0.02 then 0.02 elif . > 1 then 1 else . end)') + + crop_h=$(jq -n \ + --argjson active "$active_window" \ + --argjson monitor "$monitor_json" \ + '((($active.size[1]) / $monitor.height) | if . < 0.02 then 0.02 elif . > 1 then 1 else . end)') + + "$WAYVRCTL" screen-focus-at $refresh_flag \ + --crop-x "$crop_x" --crop-y "$crop_y" --crop-w "$crop_w" --crop-h "$crop_h" \ + "$monitor_name" "$target_x" "$target_y" + + printf '%s' "$monitor_name" > "$SCREEN_FILE" +} + +event_stream() { + python3 - "$SOCKET_PATH" <<'PY' +import socket +import sys + +sock_path = sys.argv[1] +sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +sock.connect(sock_path) +buf = b"" + +while True: + data = sock.recv(4096) + if not data: + break + buf += data + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + sys.stdout.write(line.decode("utf-8", "replace") + "\n") + sys.stdout.flush() +PY +} + +should_refresh_event() { + case "$1" in + activewindowv2*|activewindow*|fullscreen*|movewindow*|movewindowv2*|changefloatingmode*|openwindow*|closewindow*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +if [ "${1:-}" = "--watch" ]; then + watched_screen="$2" + while [ -f "$PID_FILE" ] && [ "$(cat "$PID_FILE")" = "$$" ]; do + event_stream | while IFS= read -r line; do + if [ ! -f "$PID_FILE" ] || [ "$(cat "$PID_FILE")" != "$$" ]; then + break + fi + + if should_refresh_event "$line"; then + run_focus_command --refresh-only "$watched_screen" || true + fi + done + sleep 0.2 + done + exit 0 +fi + +if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + watched_screen="$(cat "$SCREEN_FILE" 2>/dev/null || true)" + kill "$(cat "$PID_FILE")" 2>/dev/null || true + rm -f "$PID_FILE" "$SCREEN_FILE" + if [ -n "$watched_screen" ]; then + exec "$WAYVRCTL" screen-focus-toggle "$watched_screen" + fi + exit 0 +fi + +run_focus_command "" || { + echo "No active window found" + exit 1 +} + +nohup "$0" --watch "$(cat "$SCREEN_FILE")" >/dev/null 2>&1 & +printf '%s' "$!" > "$PID_FILE" diff --git a/scripts/wayvr-hypr-helper.sh b/scripts/wayvr-hypr-helper.sh new file mode 100755 index 00000000..d0f4eb65 --- /dev/null +++ b/scripts/wayvr-hypr-helper.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -e + +active_window=$(hyprctl -j activewindow) + +if [ -z "$active_window" ] || [ "$active_window" = "{}" ]; then + echo "No active window found" + exit 1 +fi + +monitor_id=$(echo "$active_window" | jq -r '.monitor') + +if [ -z "$monitor_id" ] || [ "$monitor_id" = "null" ]; then + echo "Could not get active monitor id" + exit 1 +fi + +monitor_name=$(hyprctl -j monitors | jq -r --argjson monitor_id "$monitor_id" '.[] | select(.id == $monitor_id) | .name' | head -n 1) + +if [ -z "$monitor_name" ] || [ "$monitor_name" = "null" ]; then + echo "Could not resolve monitor name for id $monitor_id" + exit 1 +fi + +exec wayvrctl screen-focus-toggle "$monitor_name" diff --git a/scripts/wayvr-hypr-local.sh b/scripts/wayvr-hypr-local.sh new file mode 100755 index 00000000..35713b2e --- /dev/null +++ b/scripts/wayvr-hypr-local.sh @@ -0,0 +1,149 @@ +#!/bin/bash +set -e + +REPO_ROOT="/home/taylor/dev/open-source/real-wayvr" +WAYVRCTL="$REPO_ROOT/target/debug/wayvrctl" +STATE_DIR="/tmp/wayvr-hypr-focus" +PID_FILE="$STATE_DIR/watch.pid" +SCREEN_FILE="$STATE_DIR/screen_name" +SOCKET_PATH="${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.socket2.sock" + +if [ ! -f "$WAYVRCTL" ]; then + echo "wayvrctl not found, building..." + cd "$REPO_ROOT" + cargo build --package wayvrctl +fi + +mkdir -p "$STATE_DIR" + +run_focus_command() { + local refresh_flag="$1" + local expected_screen="${2:-}" + + active_window=$(hyprctl -j activewindow) + + if [ -z "$active_window" ] || [ "$active_window" = "{}" ]; then + return 1 + fi + + monitor_id=$(echo "$active_window" | jq -r '.monitor') + + if [ -z "$monitor_id" ] || [ "$monitor_id" = "null" ]; then + return 1 + fi + + monitor_name=$(hyprctl -j monitors | jq -r --argjson monitor_id "$monitor_id" '.[] | select(.id == $monitor_id) | .name' | head -n 1) + monitor_json=$(hyprctl -j monitors | jq -c --argjson monitor_id "$monitor_id" '.[] | select(.id == $monitor_id)' | head -n 1) + + if [ -z "$monitor_name" ] || [ "$monitor_name" = "null" ]; then + return 1 + fi + + if [ -n "$expected_screen" ] && [ "$monitor_name" != "$expected_screen" ]; then + return 1 + fi + + target_x=$(jq -n \ + --argjson active "$active_window" \ + --argjson monitor "$monitor_json" \ + '(((($active.at[0] + ($active.size[0] / 2)) - $monitor.x) / $monitor.width) | if . < 0 then 0 elif . > 1 then 1 else . end)') + + target_y=$(jq -n \ + --argjson active "$active_window" \ + --argjson monitor "$monitor_json" \ + '(((($active.at[1] + ($active.size[1] / 2)) - $monitor.y) / $monitor.height) | if . < 0 then 0 elif . > 1 then 1 else . end)') + + crop_x=$(jq -n \ + --argjson active "$active_window" \ + --argjson monitor "$monitor_json" \ + '((($active.at[0] - $monitor.x) / $monitor.width) | if . < 0 then 0 elif . > 1 then 1 else . end)') + + crop_y=$(jq -n \ + --argjson active "$active_window" \ + --argjson monitor "$monitor_json" \ + '((($active.at[1] - $monitor.y) / $monitor.height) | if . < 0 then 0 elif . > 1 then 1 else . end)') + + crop_w=$(jq -n \ + --argjson active "$active_window" \ + --argjson monitor "$monitor_json" \ + '((($active.size[0]) / $monitor.width) | if . < 0.02 then 0.02 elif . > 1 then 1 else . end)') + + crop_h=$(jq -n \ + --argjson active "$active_window" \ + --argjson monitor "$monitor_json" \ + '((($active.size[1]) / $monitor.height) | if . < 0.02 then 0.02 elif . > 1 then 1 else . end)') + + "$WAYVRCTL" screen-focus-at $refresh_flag \ + --crop-x "$crop_x" --crop-y "$crop_y" --crop-w "$crop_w" --crop-h "$crop_h" \ + "$monitor_name" "$target_x" "$target_y" + + printf '%s' "$monitor_name" > "$SCREEN_FILE" +} + +event_stream() { + python3 - "$SOCKET_PATH" <<'PY' +import socket +import sys + +sock_path = sys.argv[1] +sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +sock.connect(sock_path) +buf = b"" + +while True: + data = sock.recv(4096) + if not data: + break + buf += data + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + sys.stdout.write(line.decode("utf-8", "replace") + "\n") + sys.stdout.flush() +PY +} + +should_refresh_event() { + case "$1" in + activewindowv2*|activewindow*|fullscreen*|movewindow*|movewindowv2*|changefloatingmode*|openwindow*|closewindow*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +if [ "${1:-}" = "--watch" ]; then + watched_screen="$2" + while [ -f "$PID_FILE" ] && [ "$(cat "$PID_FILE")" = "$$" ]; do + event_stream | while IFS= read -r line; do + if [ ! -f "$PID_FILE" ] || [ "$(cat "$PID_FILE")" != "$$" ]; then + break + fi + + if should_refresh_event "$line"; then + run_focus_command --refresh-only "$watched_screen" || true + fi + done + sleep 0.2 + done + exit 0 +fi + +if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + watched_screen="$(cat "$SCREEN_FILE" 2>/dev/null || true)" + kill "$(cat "$PID_FILE")" 2>/dev/null || true + rm -f "$PID_FILE" "$SCREEN_FILE" + if [ -n "$watched_screen" ]; then + exec "$WAYVRCTL" screen-focus-toggle "$watched_screen" + fi + exit 0 +fi + +run_focus_command "" || { + echo "No active window found" + exit 1 +} + +nohup "$0" --watch "$(cat "$SCREEN_FILE")" >/dev/null 2>&1 & +printf '%s' "$!" > "$PID_FILE" diff --git a/scripts/wayvr-hypr-screen-toggle.sh b/scripts/wayvr-hypr-screen-toggle.sh new file mode 100755 index 00000000..f1830290 --- /dev/null +++ b/scripts/wayvr-hypr-screen-toggle.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -e + +REPO_ROOT="/home/taylor/dev/open-source/real-wayvr" +WAYVRCTL="$REPO_ROOT/target/debug/wayvrctl" + +if [ ! -f "$WAYVRCTL" ]; then + echo "wayvrctl not found, building..." + cd "$REPO_ROOT" + cargo build --package wayvrctl +fi + +active_window=$(hyprctl -j activewindow) + +if [ -z "$active_window" ] || [ "$active_window" = "{}" ]; then + echo "No active window found" + exit 1 +fi + +monitor_id=$(echo "$active_window" | jq -r '.monitor') + +if [ -z "$monitor_id" ] || [ "$monitor_id" = "null" ]; then + echo "Could not get active monitor id" + exit 1 +fi + +monitor_name=$(hyprctl -j monitors | jq -r --argjson monitor_id "$monitor_id" '.[] | select(.id == $monitor_id) | .name' | head -n 1) + +if [ -z "$monitor_name" ] || [ "$monitor_name" = "null" ]; then + echo "Could not resolve monitor name for id $monitor_id" + exit 1 +fi + +exec "$WAYVRCTL" screen-focus-toggle "$monitor_name" diff --git a/wayvr-ipc/src/client.rs b/wayvr-ipc/src/client.rs index b1c4c598..19a8dcca 100644 --- a/wayvr-ipc/src/client.rs +++ b/wayvr-ipc/src/client.rs @@ -429,6 +429,14 @@ impl WayVRClient { send_only!(client, &PacketClient::WlxModifyPanel(params)); Ok(()) } + + pub async fn fn_wlx_screen_focus_toggle( + client: WayVRClientMutex, + params: packet_client::WlxScreenFocusToggleParams, + ) -> anyhow::Result<()> { + send_only!(client, &PacketClient::WlxScreenFocusToggle(params)); + Ok(()) + } } impl Drop for WayVRClient { diff --git a/wayvr-ipc/src/packet_client.rs b/wayvr-ipc/src/packet_client.rs index 6f8649bb..f53cc46f 100644 --- a/wayvr-ipc/src/packet_client.rs +++ b/wayvr-ipc/src/packet_client.rs @@ -55,11 +55,21 @@ pub struct WlxModifyPanelParams { pub command: WlxModifyPanelCommand, } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WlxScreenFocusToggleParams { + pub screen_name: String, + pub target_x: f32, + pub target_y: f32, + pub crop_rect: Option<[f32; 4]>, + pub refresh_only: bool, +} + #[derive(Debug, Serialize, Deserialize)] pub enum PacketClient { Handshake(Handshake), WvrWindowList(Serial), WvrWindowSetVisible(packet_server::WvrWindowHandle, bool), + WlxScreenFocusToggle(WlxScreenFocusToggleParams), WvrProcessGet(Serial, packet_server::WvrProcessHandle), WvrProcessLaunch(Serial, WvrProcessLaunchParams), WvrProcessList(Serial), diff --git a/wayvr/src/backend/openxr/mod.rs b/wayvr/src/backend/openxr/mod.rs index 8e7938f7..28657287 100644 --- a/wayvr/src/backend/openxr/mod.rs +++ b/wayvr/src/backend/openxr/mod.rs @@ -317,6 +317,7 @@ pub fn openxr_run(show_by_default: bool, headless: bool) -> Result<(), BackendEr .submit(&mut app); } + overlays.animate_focus_transitions(&mut app); overlays.values_mut().for_each(|o| o.config.tick(&mut app)); current_lines.clear(); diff --git a/wayvr/src/backend/task.rs b/wayvr/src/backend/task.rs index 002881e5..2ee0bcc1 100644 --- a/wayvr/src/backend/task.rs +++ b/wayvr/src/backend/task.rs @@ -85,6 +85,15 @@ pub struct ModifyPanelTask { pub command: ModifyPanelCommand, } +#[derive(Debug, Clone)] +pub struct ScreenFocusTask { + pub screen_name: String, + pub target_x: f32, + pub target_y: f32, + pub crop_rect: Option<[f32; 4]>, + pub refresh_only: bool, +} + pub enum ToggleMode { EnsureOn, EnsureOff, @@ -110,6 +119,7 @@ pub enum OverlayTask { Create(OverlaySelector, Box), ModifyPanel(ModifyPanelTask), Drop(OverlaySelector), + ScreenFocusToggle(ScreenFocusTask), } #[allow(dead_code)] diff --git a/wayvr/src/config.rs b/wayvr/src/config.rs index 9eb28d2b..905b8b12 100644 --- a/wayvr/src/config.rs +++ b/wayvr/src/config.rs @@ -131,6 +131,13 @@ pub struct AutoSettings { pub use_passthrough: bool, pub screen_render_down: bool, pub pointer_lerp_factor: f32, + pub focused_screen_assist_x: f32, + pub focused_screen_assist_y: f32, + pub focused_screen_rotate_assist_x: f32, + pub focused_screen_rotate_assist_y: f32, + pub focused_screen_curve_x: f32, + pub focused_screen_distance: f32, + pub focused_screen_scale: f32, pub space_drag_unlocked: bool, pub space_rotate_unlocked: bool, pub clock_12h: bool, @@ -148,7 +155,7 @@ pub struct AutoSettings { fn get_settings_path() -> PathBuf { config_io::ConfigRoot::Generic .get_conf_d_path() - .join("zz-saved-config.json5") + .join("zz-saved-config.yaml") } pub fn save_settings(config: &GeneralConfig) -> anyhow::Result<()> { @@ -182,6 +189,13 @@ pub fn save_settings(config: &GeneralConfig) -> anyhow::Result<()> { use_passthrough: config.use_passthrough, screen_render_down: config.screen_render_down, pointer_lerp_factor: config.pointer_lerp_factor, + focused_screen_assist_x: config.focused_screen_assist_x, + focused_screen_assist_y: config.focused_screen_assist_y, + focused_screen_rotate_assist_x: config.focused_screen_rotate_assist_x, + focused_screen_rotate_assist_y: config.focused_screen_rotate_assist_y, + focused_screen_curve_x: config.focused_screen_curve_x, + focused_screen_distance: config.focused_screen_distance, + focused_screen_scale: config.focused_screen_scale, space_drag_unlocked: config.space_drag_unlocked, space_rotate_unlocked: config.space_rotate_unlocked, clock_12h: config.clock_12h, @@ -196,8 +210,8 @@ pub fn save_settings(config: &GeneralConfig) -> anyhow::Result<()> { language: config.language, }; - let json = serde_json::to_string_pretty(&conf).unwrap(); // want panic - std::fs::write(get_settings_path(), json)?; + let yaml = serde_yaml::to_string(&conf).unwrap(); // want panic + std::fs::write(get_settings_path(), yaml)?; log::info!("Saved settings."); Ok(()) diff --git a/wayvr/src/ipc/events.rs b/wayvr/src/ipc/events.rs index ac0c6591..aacd4a02 100644 --- a/wayvr/src/ipc/events.rs +++ b/wayvr/src/ipc/events.rs @@ -58,6 +58,12 @@ where app.tasks .enqueue(TaskType::Overlay(OverlayTask::ModifyPanel(custom_task))); } + WayVRSignal::ScreenFocusToggle(screen_focus) => { + app.tasks + .enqueue(TaskType::Overlay(OverlayTask::ScreenFocusToggle( + screen_focus, + ))); + } } } diff --git a/wayvr/src/ipc/ipc_server.rs b/wayvr/src/ipc/ipc_server.rs index 4490ac21..6eea4111 100644 --- a/wayvr/src/ipc/ipc_server.rs +++ b/wayvr/src/ipc/ipc_server.rs @@ -382,6 +382,21 @@ impl Connection { })); } + fn handle_wlx_screen_focus_toggle( + params: &mut TickParams, + screen_focus: packet_client::WlxScreenFocusToggleParams, + ) { + params.signals.send(WayVRSignal::ScreenFocusToggle( + crate::backend::task::ScreenFocusTask { + screen_name: screen_focus.screen_name, + target_x: screen_focus.target_x.clamp(0.0, 1.0), + target_y: screen_focus.target_y.clamp(0.0, 1.0), + crop_rect: screen_focus.crop_rect.map(sanitize_crop_rect), + refresh_only: screen_focus.refresh_only, + }, + )); + } + // FIXME: we should probably respond an error to the client in case if wayland server feature is disabled // fix this after we're done with the webkit-based wayvr-dashboard #[allow(unused_variables)] @@ -404,6 +419,9 @@ impl Connection { PacketClient::WvrWindowSetVisible(window_handle, visible) => { Self::handle_wvr_window_set_visible(params, window_handle, visible); } + PacketClient::WlxScreenFocusToggle(screen_focus) => { + Self::handle_wlx_screen_focus_toggle(params, screen_focus); + } PacketClient::WvrProcessGet(serial, process_handle) => { self.handle_wvr_process_get(params, serial, process_handle)?; } @@ -496,6 +514,56 @@ impl Connection { } } +fn sanitize_crop_rect(crop_rect: [f32; 4]) -> [f32; 4] { + let raw_w = if crop_rect[2].is_finite() { + crop_rect[2].clamp(0.01, 1.0) + } else { + 1.0 + }; + let raw_h = if crop_rect[3].is_finite() { + crop_rect[3].clamp(0.01, 1.0) + } else { + 1.0 + }; + + let x = if crop_rect[0].is_finite() { + crop_rect[0].clamp(0.0, 1.0 - raw_w) + } else { + 0.0 + }; + let y = if crop_rect[1].is_finite() { + crop_rect[1].clamp(0.0, 1.0 - raw_h) + } else { + 0.0 + }; + + let w = raw_w.min(1.0 - x).max(0.01); + let h = raw_h.min(1.0 - y).max(0.01); + [x, y, w, h] +} + +#[cfg(test)] +mod tests { + use super::sanitize_crop_rect; + + #[test] + fn sanitize_crop_rect_prevents_edge_panic() { + let crop = sanitize_crop_rect([1.0, 1.0, 0.02, 0.02]); + assert!((crop[0] - 0.98).abs() < 0.0001); + assert!((crop[1] - 0.98).abs() < 0.0001); + assert!((crop[2] - 0.02).abs() < 0.0001); + assert!((crop[3] - 0.02).abs() < 0.0001); + } + + #[test] + fn sanitize_crop_rect_handles_non_finite_values() { + assert_eq!( + sanitize_crop_rect([f32::NAN, f32::INFINITY, f32::NAN, -1.0]), + [0.0, 0.0, 1.0, 0.01] + ); + } +} + impl Drop for Connection { fn drop(&mut self) { log::info!("Connection closed"); diff --git a/wayvr/src/ipc/signal.rs b/wayvr/src/ipc/signal.rs index 87111ee3..b698c04b 100644 --- a/wayvr/src/ipc/signal.rs +++ b/wayvr/src/ipc/signal.rs @@ -5,4 +5,5 @@ pub enum WayVRSignal { SwitchSet(Option), ShowHide, CustomTask(crate::backend::task::ModifyPanelTask), + ScreenFocusToggle(crate::backend::task::ScreenFocusTask), } diff --git a/wayvr/src/overlays/screen/backend.rs b/wayvr/src/overlays/screen/backend.rs index 92b6ba3f..732ab42f 100644 --- a/wayvr/src/overlays/screen/backend.rs +++ b/wayvr/src/overlays/screen/backend.rs @@ -57,11 +57,14 @@ pub struct ScreenBackend { stereo: Option, stereo_full_frame: bool, stereo_adjust_mouse: bool, + crop_rect: [f32; 4], + crop_dirty: bool, pub(super) logical_pos: Vec2, pub(super) logical_size: Vec2, pub(super) mouse_transform_original: Transform, mouse_transform_override: MouseTransform, just_resumed: bool, + source_meta: Option, } impl ScreenBackend { @@ -87,17 +90,22 @@ impl ScreenBackend { }, stereo_full_frame: false, stereo_adjust_mouse: false, + crop_rect: [0.0, 0.0, 1.0, 1.0], + crop_dirty: false, logical_pos: Vec2::ZERO, logical_size: Vec2::ZERO, mouse_transform_original: Transform::Undefined, mouse_transform_override: MouseTransform::Default, just_resumed: false, + source_meta: None, } } pub(super) fn apply_mouse_transform_with_override(&mut self, override_transform: Transform) { - let mut size = self.logical_size; - let pos = self.logical_pos; + let crop_origin = Vec2::new(self.crop_rect[0], self.crop_rect[1]); + let crop_size = Vec2::new(self.crop_rect[2], self.crop_rect[3]); + let mut size = self.logical_size * crop_size; + let pos = self.logical_pos + self.logical_size * crop_origin; if self.stereo_adjust_mouse && let Some(stereo) = self.stereo.as_ref() @@ -153,6 +161,64 @@ impl ScreenBackend { ), }; } + + fn effective_extent(&self, source_extent: [u32; 2]) -> [u32; 2] { + [ + (source_extent[0] as f32 * self.crop_rect[2]) + .round() + .max(1.0) as u32, + (source_extent[1] as f32 * self.crop_rect[3]) + .round() + .max(1.0) as u32, + ] + } + + fn apply_meta_state( + &mut self, + app: &mut AppState, + mut source_meta: FrameMeta, + stereo: StereoMode, + ) -> anyhow::Result<()> { + let source_extent = source_meta.extent; + let effective_extent = self.effective_extent(source_extent); + source_meta.extent = effective_extent; + + if let Some(pipeline) = self.pipeline.as_mut() { + if self + .meta + .is_some_and(|old| old.extent != source_meta.extent) + { + pipeline.set_extent( + app, + [source_meta.extent[0] as f32, source_meta.extent[1] as f32], + [0.0, 0.0], + [source_extent[0] as f32, source_extent[1] as f32], + )?; + self.interaction_transform = Some(ui_transform(source_meta.extent)); + } + if self.crop_dirty { + pipeline.set_crop_rect(self.crop_rect)?; + } + pipeline.ensure_stereo(stereo); + } else { + let pipeline = ScreenPipeline::new( + &source_meta, + source_extent, + app, + stereo, + [0.0, 0.0], + self.crop_rect, + )?; + self.pipeline = Some(pipeline); + self.interaction_transform = Some(ui_transform(source_meta.extent)); + } + + self.meta = Some(source_meta); + self.crop_dirty = false; + let frame_transform = mouse_transform_to_transform(self.mouse_transform_override); + self.apply_mouse_transform_with_override(frame_transform); + Ok(()) + } } impl OverlayBackend for ScreenBackend { @@ -237,26 +303,19 @@ impl OverlayBackend for ScreenBackend { } } - if let Some(pipeline) = self.pipeline.as_mut() { - if self.meta.is_some_and(|old| old.extent != meta.extent) { - pipeline.set_extent( - app, - [meta.extent[0] as _, meta.extent[1] as _], - [0., 0.], - )?; - self.interaction_transform = Some(ui_transform(meta.extent)); - } - } else { - let pipeline = ScreenPipeline::new(&meta, app, stereo, [0., 0.])?; - self.pipeline = Some(pipeline); - self.interaction_transform = Some(ui_transform(meta.extent)); - } - - self.meta = Some(meta); + self.source_meta = Some(meta); + self.apply_meta_state(app, meta, stereo)?; self.cur_frame = Some(frame); Ok(ShouldRender::Should) } else if self.cur_frame.is_some() { + if self.crop_dirty + && let Some(source_meta) = self.source_meta + { + let stereo = self.stereo.unwrap_or(StereoMode::None); + self.apply_meta_state(app, source_meta, stereo)?; + return Ok(ShouldRender::Should); + } if self.just_resumed { self.just_resumed = false; Ok(ShouldRender::Should) @@ -364,6 +423,7 @@ impl OverlayBackend for ScreenBackend { BackendAttrib::StereoAdjustMouse => Some(BackendAttribValue::StereoAdjustMouse( self.stereo_adjust_mouse, )), + BackendAttrib::CropRect => Some(BackendAttribValue::CropRect(self.crop_rect)), _ => None, } } @@ -397,6 +457,13 @@ impl OverlayBackend for ScreenBackend { self.apply_mouse_transform_with_override(frame_transform); true } + BackendAttribValue::CropRect(new) => { + self.crop_rect = new; + self.crop_dirty = true; + let frame_transform = mouse_transform_to_transform(self.mouse_transform_override); + self.apply_mouse_transform_with_override(frame_transform); + true + } _ => false, } } diff --git a/wayvr/src/overlays/screen/capture.rs b/wayvr/src/overlays/screen/capture.rs index a37e4b42..b3b8aa46 100644 --- a/wayvr/src/overlays/screen/capture.rs +++ b/wayvr/src/overlays/screen/capture.rs @@ -54,18 +54,23 @@ pub struct ScreenPipeline { pass: SmallVec<[BufPass; 2]>, pipeline: Arc>, extentf: [f32; 2], + source_extentf: [f32; 2], offsetf: [f32; 2], + crop_rect: [f32; 4], stereo: StereoMode, } impl ScreenPipeline { pub fn new( meta: &FrameMeta, + source_extent: [u32; 2], app: &mut AppState, stereo: StereoMode, offsetf: [f32; 2], + crop_rect: [f32; 4], ) -> anyhow::Result { let extentf = [meta.extent[0] as f32, meta.extent[1] as f32]; + let source_extentf = [source_extent[0] as f32, source_extent[1] as f32]; let pipeline = app.gfx.create_pipeline( app.gfx_extras.shaders.get("vert_quad").unwrap(), // want panic @@ -80,7 +85,9 @@ impl ScreenPipeline { mouse: Self::create_mouse_pass(app, pipeline.clone(), extentf, offsetf)?, pipeline, extentf, + source_extentf, offsetf, + crop_rect, stereo, }; me.ensure_stereo(stereo); @@ -110,8 +117,10 @@ impl ScreenPipeline { self.pass.pop(); } + let stereo = self.stereo; + let crop_rect = self.crop_rect; for (eye, current) in self.pass.iter_mut().enumerate() { - let verts = stereo_mode_to_verts(self.stereo, eye); + let verts = cropped_stereo_mode_to_verts(stereo, crop_rect, eye); current.buf_vert.write()?.copy_from_slice(&verts); } Ok(()) @@ -122,8 +131,10 @@ impl ScreenPipeline { app: &mut AppState, extentf: [f32; 2], offsetf: [f32; 2], + source_extentf: [f32; 2], ) -> anyhow::Result<()> { self.extentf = extentf; + self.source_extentf = source_extentf; self.offsetf = offsetf; self.pass.clear(); @@ -131,6 +142,16 @@ impl ScreenPipeline { Ok(()) } + pub fn set_crop_rect(&mut self, crop_rect: [f32; 4]) -> anyhow::Result<()> { + self.crop_rect = crop_rect; + let stereo = self.stereo; + for (eye, current) in self.pass.iter_mut().enumerate() { + let verts = cropped_stereo_mode_to_verts(stereo, crop_rect, eye); + current.buf_vert.write()?.copy_from_slice(&verts); + } + Ok(()) + } + fn create_pass( app: &mut AppState, pipeline: Arc>, @@ -220,15 +241,26 @@ impl ScreenPipeline { cmd_buf.run_ref(¤t.pass)?; if let Some(mouse) = mouse.as_ref() { - let size = CURSOR_SIZE * self.extentf[1]; + let [crop_x, crop_y, crop_w, crop_h] = self.crop_rect; + if mouse.x < crop_x + || mouse.x > crop_x + crop_w + || mouse.y < crop_y + || mouse.y > crop_y + crop_h + { + continue; + } + + let local_mouse_x = (mouse.x - crop_x) / crop_w; + let local_mouse_y = (mouse.y - crop_y) / crop_h; + let size = CURSOR_SIZE * self.source_extentf[1] / crop_h.max(0.01); let half_size = size * 0.5; upload_quad_vertices( &mut self.mouse.buf_vert, self.extentf[0], self.extentf[1], - mouse.x.mul_add(self.extentf[0], -half_size), - mouse.y.mul_add(self.extentf[1], -half_size), + local_mouse_x.mul_add(self.extentf[0], -half_size), + local_mouse_y.mul_add(self.extentf[1], -half_size), size, size, )?; @@ -241,6 +273,21 @@ impl ScreenPipeline { } } +fn cropped_stereo_mode_to_verts( + stereo: StereoMode, + crop_rect: [f32; 4], + array_index: usize, +) -> [Vert2Uv; 4] { + let [crop_x, crop_y, crop_w, crop_h] = crop_rect; + stereo_mode_to_verts(stereo, array_index).map(|vert| Vert2Uv { + in_pos: vert.in_pos, + in_uv: [ + crop_x + vert.in_uv[0] * crop_w, + crop_y + vert.in_uv[1] * crop_h, + ], + }) +} + fn stereo_mode_to_verts(stereo: StereoMode, array_index: usize) -> [Vert2Uv; 4] { let eye = match stereo { StereoMode::RightLeft | StereoMode::BottomTop => (1 - array_index) as f32, diff --git a/wayvr/src/overlays/wayvr.rs b/wayvr/src/overlays/wayvr.rs index 2b0b0274..a0ebe5a2 100644 --- a/wayvr/src/overlays/wayvr.rs +++ b/wayvr/src/overlays/wayvr.rs @@ -352,6 +352,7 @@ impl OverlayBackend for WvrWindowBackend { app, [inner_extent[0] as _, inner_extent[1] as _], [BORDER_SIZE as _, (BAR_SIZE + BORDER_SIZE) as _], + [inner_extent[0] as _, inner_extent[1] as _], )?; self.apply_extent(app, &meta)?; self.inner_extent = inner_extent; @@ -359,9 +360,11 @@ impl OverlayBackend for WvrWindowBackend { } else { let pipeline = ScreenPipeline::new( &meta, + inner_extent, app, self.stereo.unwrap_or(StereoMode::None), [BORDER_SIZE as _, (BAR_SIZE + BORDER_SIZE) as _], + [0.0, 0.0, 1.0, 1.0], )?; self.apply_extent(app, &meta)?; self.pipeline = Some(pipeline); diff --git a/wayvr/src/windowing/manager.rs b/wayvr/src/windowing/manager.rs index 5d8a168c..7669a3b9 100644 --- a/wayvr/src/windowing/manager.rs +++ b/wayvr/src/windowing/manager.rs @@ -1,17 +1,20 @@ use std::{ collections::{HashMap, VecDeque}, rc::Rc, + sync::Arc, sync::atomic::Ordering, }; use anyhow::Context; -use glam::{Affine3A, Vec3, Vec3A}; +use glam::{Affine3A, Quat, Vec3, Vec3A}; use slotmap::{Key, SecondaryMap, SlotMap}; +use wgui::animation::AnimationEasing; use wgui::log::LogErr; use wlx_common::{ astr_containers::{AStrMap, AStrMapExt}, config::SerializedWindowSet, overlays::{BackendAttrib, BackendAttribValue, ToastTopic}, + timestep::get_micros, }; use crate::{ @@ -26,7 +29,7 @@ use crate::{ keyboard::create_keyboard, screen::create_screens, toast::Toast, - watch::{WATCH_NAME, create_watch}, + watch::create_watch, }, state::AppState, windowing::{ @@ -38,8 +41,31 @@ use crate::{ }, }; +use wlx_common::windowing::OverlayWindowState; + pub const MAX_OVERLAY_SETS: usize = 6; +struct FocusAnimation { + oid: OverlayID, + from: OverlayWindowState, + to: OverlayWindowState, + start_us: u64, + duration_us: u64, + easing: AnimationEasing, +} + +#[derive(Clone)] +struct FocusedScreenState { + name: Arc, + oid: OverlayID, + saved_state: OverlayWindowState, + saved_crop_rect: [f32; 4], + focus_anchor: Affine3A, + target_x: f32, + target_y: f32, + crop_rect: [f32; 4], +} + pub struct OverlayWindowManager { wrappers: EditWrapperManager, overlays: SlotMap>, @@ -56,6 +82,8 @@ pub struct OverlayWindowManager { edit_mode: bool, dropped_overlays: VecDeque>, initialized: bool, + focused_screen: Option, + focus_animation: Option, } impl OverlayWindowManager @@ -71,11 +99,13 @@ where sets: vec![OverlayWindowSet::default()], global_set: OverlayWindowSet::default(), anchor_local: Affine3A::from_translation(Vec3::NEG_Z), - watch_id: OverlayID::null(), // set down below - keyboard_id: OverlayID::null(), // set down below + watch_id: OverlayID::null(), + keyboard_id: OverlayID::null(), edit_mode: false, dropped_overlays: VecDeque::with_capacity(8), initialized: false, + focused_screen: None, + focus_animation: None, }; let mut wayland = false; @@ -382,6 +412,9 @@ where )?; } } + OverlayTask::ScreenFocusToggle(screen_focus) => { + self.handle_screen_focus_toggle(app, screen_focus)?; + } } Ok(()) } @@ -394,6 +427,53 @@ const SAVED_ATTRIBS: [BackendAttrib; 3] = [ ]; impl OverlayWindowManager { + pub fn animate_focus_transitions(&mut self, app: &mut AppState) { + if let Some(anim) = self.focus_animation.as_ref() { + let raw = if anim.duration_us == 0 { + 1.0 + } else { + ((get_micros().saturating_sub(anim.start_us)) as f32 / anim.duration_us as f32) + .clamp(0.0, 1.0) + }; + let pos = anim.easing.interpolate(raw); + let is_done = raw >= 1.0; + let oid = anim.oid; + let state = interpolate_overlay_state(&anim.from, &anim.to, pos); + + if let Some(overlay) = self.mut_by_id(oid) { + overlay.config.active_state = Some(state); + overlay.config.dirty = true; + } + + if is_done { + self.focus_animation = None; + } + return; + } + + if let Some(focused) = self.focused_screen.clone() { + if let Some(overlay) = self.mut_by_id(focused.oid) { + let aspect_ratio = overlay + .frame_meta() + .map(|meta| meta.extent[0] as f32 / meta.extent[1] as f32) + .unwrap_or(1.0) + .max(0.01); + let refreshed_focus_state = build_focused_screen_state( + &focused.saved_state, + app, + focused.focus_anchor, + aspect_ratio, + focused.target_x, + focused.target_y, + focused.crop_rect, + ); + let assisted_state = apply_focus_look_assist(&refreshed_focus_state, app); + overlay.config.active_state = Some(assisted_state); + overlay.config.dirty = true; + } + } + } + pub fn pop_dropped(&mut self) -> Option> { self.dropped_overlays.pop_front() } @@ -516,12 +596,7 @@ impl OverlayWindowManager { // global overlays for (name, ows) in app.session.config.global_set.clone() { - let mut ows = ows.clone(); - - // fix angle_fade missing on watch if loading older state - if name.as_ref() == WATCH_NAME { - ows.angle_fade = true; - } + let ows = ows.clone(); if let Some(oid) = self.lookup(&name) && let Some(o) = self.mut_by_id(oid) @@ -937,4 +1012,331 @@ impl OverlayWindowManager { Ok(()) } + + fn handle_screen_focus_toggle( + &mut self, + app: &mut AppState, + screen_focus: crate::backend::task::ScreenFocusTask, + ) -> anyhow::Result<()> { + let screen_name: Arc = screen_focus.screen_name.into(); + let mut carry_saved_state: Option = None; + let mut carry_saved_crop_rect: Option<[f32; 4]> = None; + let mut carry_current_state: Option = None; + + if let Some(focused_screen) = self.focused_screen.take() { + let focused_name = focused_screen.name; + let focused_oid = focused_screen.oid; + let saved_state = focused_screen.saved_state; + let saved_crop_rect = focused_screen.saved_crop_rect; + let current_state = self + .mut_by_id(focused_oid) + .and_then(|overlay| overlay.config.active_state.clone()) + .unwrap_or_else(|| saved_state.clone()); + + if screen_focus.refresh_only && focused_name == screen_name { + carry_saved_state = Some(saved_state); + carry_saved_crop_rect = Some(saved_crop_rect); + carry_current_state = Some(current_state); + } else { + if let Some(overlay) = self.mut_by_id(focused_oid) { + overlay + .config + .backend + .set_attrib(app, BackendAttribValue::CropRect(saved_crop_rect)); + overlay.config.active_state = Some(saved_state.clone()); + overlay.config.dirty = true; + } + + self.focus_animation = Some(FocusAnimation { + oid: focused_oid, + from: current_state, + to: saved_state.clone(), + start_us: get_micros(), + duration_us: 260_000, + easing: AnimationEasing::OutCubic, + }); + + if focused_name == screen_name { + log::info!("Screen focus: restored previous state for {}", screen_name); + return Ok(()); + } + } + } + + if screen_focus.refresh_only && carry_saved_state.is_none() { + return Ok(()); + } + + let Some(target_oid) = self.lookup(&screen_name) else { + log::warn!("Screen focus: no overlay found for screen {}", screen_name); + return Ok(()); + }; + + let Some(overlay) = self.mut_by_id(target_oid) else { + log::warn!("Screen focus: overlay {:?} not found", target_oid); + return Ok(()); + }; + + if !matches!(overlay.config.category, OverlayCategory::Screen) { + log::warn!("Screen focus: overlay {} is not a screen", screen_name); + return Ok(()); + } + + let saved_state = carry_saved_state.unwrap_or_else(|| { + overlay + .config + .active_state + .clone() + .unwrap_or_else(OverlayWindowState::default) + }); + let saved_crop_rect = carry_saved_crop_rect.unwrap_or_else(|| { + overlay + .config + .backend + .get_attrib(BackendAttrib::CropRect) + .and_then(|value| match value { + BackendAttribValue::CropRect(crop_rect) => Some(crop_rect), + _ => None, + }) + .unwrap_or([0.0, 0.0, 1.0, 1.0]) + }); + + let frame_meta = overlay.frame_meta(); + let aspect_ratio = frame_meta + .map(|meta| meta.extent[0] as f32 / meta.extent[1] as f32) + .unwrap_or(1.0) + .max(0.01); + + let current_state = carry_current_state.unwrap_or_else(|| { + overlay + .config + .active_state + .clone() + .unwrap_or_else(|| saved_state.clone()) + }); + + let crop_rect = screen_focus.crop_rect.unwrap_or([0.0, 0.0, 1.0, 1.0]); + overlay + .config + .backend + .set_attrib(app, BackendAttribValue::CropRect(crop_rect)); + + let focus_anchor = snap_upright(app.input_state.hmd, Vec3A::Y); + let focused_state = build_focused_screen_state( + &saved_state, + app, + focus_anchor, + aspect_ratio, + screen_focus.target_x, + screen_focus.target_y, + crop_rect, + ); + + overlay.config.active_state = Some(focused_state.clone()); + overlay.config.dirty = true; + + self.focused_screen = Some(FocusedScreenState { + name: screen_name.clone(), + oid: target_oid, + saved_state, + saved_crop_rect, + focus_anchor, + target_x: screen_focus.target_x, + target_y: screen_focus.target_y, + crop_rect, + }); + self.focus_animation = Some(FocusAnimation { + oid: target_oid, + from: current_state, + to: focused_state, + start_us: get_micros(), + duration_us: 320_000, + easing: AnimationEasing::OutBack, + }); + + log::info!( + "Screen focus: focused screen {} on overlay {:?}", + screen_name, + target_oid + ); + Ok(()) + } +} + +fn interpolate_overlay_state( + from: &OverlayWindowState, + to: &OverlayWindowState, + t: f32, +) -> OverlayWindowState { + let (from_scale, from_rot, from_trans) = from.transform.to_scale_rotation_translation(); + let (to_scale, to_rot, to_trans) = to.transform.to_scale_rotation_translation(); + + let mut state = to.clone(); + state.transform = Affine3A::from_scale_rotation_translation( + from_scale.lerp(to_scale, t), + from_rot.slerp(to_rot, t), + from_trans.lerp(to_trans, t), + ); + state.alpha = from.alpha + (to.alpha - from.alpha) * t; + state +} + +fn build_focused_screen_state( + saved_state: &OverlayWindowState, + app: &AppState, + focus_anchor: Affine3A, + aspect_ratio: f32, + target_x: f32, + target_y: f32, + crop_rect: [f32; 4], +) -> OverlayWindowState { + use wlx_common::windowing::Positioning; + + let focus_scale = saved_state.transform.matrix3.y_axis.length().max(0.01) + * app.session.config.focused_screen_scale.max(0.01); + let mut focus_transform = focus_anchor + * Affine3A::from_scale_rotation_translation( + Vec3::splat(focus_scale), + Quat::IDENTITY, + Vec3::new( + 0.0, + 0.0, + -app.session.config.focused_screen_distance.max(0.05), + ), + ); + + let (_, focus_rotation, _) = focus_transform.to_scale_rotation_translation(); + let width = focus_scale; + let height = focus_scale / aspect_ratio.max(0.01); + let offset_strength = if crop_rect != [0.0, 0.0, 1.0, 1.0] { + 0.0 + } else { + 0.35 + }; + let local_x = (0.5 - target_x) * width * offset_strength; + let local_y = (target_y - 0.5) * height * offset_strength; + let offset_world = Vec3A::from(focus_rotation.mul_vec3(Vec3::new(local_x, local_y, 0.0))); + focus_transform.translation += offset_world; + let mut focused_state = saved_state.clone(); + let curve_x = resolve_focused_screen_curvature( + saved_state.curvature, + app.session.config.focused_screen_curve_x, + ); + focused_state.transform = focus_transform; + focused_state.positioning = Positioning::Static; + focused_state.interactable = true; + focused_state.grabbable = true; + focused_state.curvature = Some(curve_x); + focused_state +} + +fn resolve_focused_screen_curvature(saved_curvature: Option, configured_curve_x: f32) -> f32 { + saved_curvature + .unwrap_or(0.15) + .max(configured_curve_x.max(0.0)) +} + +fn apply_focus_look_assist(base: &OverlayWindowState, app: &AppState) -> OverlayWindowState { + let mut state = base.clone(); + let (scale, rotation, translation) = base.transform.to_scale_rotation_translation(); + + let hmd_forward = app + .input_state + .hmd + .transform_vector3a(Vec3A::NEG_Z) + .normalize(); + let local_forward = rotation.inverse().mul_vec3a(hmd_forward); + + let aspect_ratio = (scale.x / scale.y).max(0.01); + let width = scale.x; + let height = width / aspect_ratio; + + let (assist_offset, assist_rotation) = resolve_focus_look_assist( + local_forward, + width, + height, + app.session.config.focused_screen_assist_x.max(0.0), + app.session.config.focused_screen_assist_y.max(0.0), + app.session.config.focused_screen_rotate_assist_x.max(0.0), + app.session.config.focused_screen_rotate_assist_y.max(0.0), + ); + let assist_world = rotation.mul_vec3(assist_offset); + + state.transform = Affine3A::from_scale_rotation_translation( + scale, + rotation * assist_rotation, + translation + assist_world, + ); + state +} + +fn resolve_focus_look_assist( + local_forward: Vec3A, + width: f32, + height: f32, + translate_assist_x: f32, + translate_assist_y: f32, + rotate_assist_x: f32, + rotate_assist_y: f32, +) -> (Vec3, Quat) { + let clamped_x = (-local_forward.x).clamp(-0.55, 0.55); + let clamped_y = (-local_forward.y).clamp(-0.5, 0.5); + + let assist_offset = Vec3::new( + clamped_x * width * translate_assist_x, + clamped_y * height * translate_assist_y, + 0.0, + ); + + let assist_rotation = Quat::from_rotation_y(clamped_x * rotate_assist_x) + * Quat::from_rotation_x(-clamped_y * rotate_assist_y); + + (assist_offset, assist_rotation) +} + +#[cfg(test)] +mod tests { + use glam::{Quat, Vec3, Vec3A}; + + use super::{resolve_focus_look_assist, resolve_focused_screen_curvature}; + + #[test] + fn focused_screen_curvature_uses_saved_or_configured_curve() { + let curve_x = resolve_focused_screen_curvature(Some(0.2), 0.3); + assert!((curve_x - 0.3).abs() < f32::EPSILON); + + let curve_x = resolve_focused_screen_curvature(None, 0.32); + assert!((curve_x - 0.32).abs() < f32::EPSILON); + } + + #[test] + fn focus_look_assist_is_neutral_when_forward_is_centered() { + let (offset, rotation) = + resolve_focus_look_assist(Vec3A::new(0.0, 0.0, -1.0), 1.2, 0.8, 0.13, 0.18, 0.13, 0.12); + + assert!(offset.length() < f32::EPSILON); + assert!(rotation.abs_diff_eq(Quat::IDENTITY, f32::EPSILON)); + } + + #[test] + fn focus_look_assist_adds_yaw_pitch_without_roll() { + let (offset, rotation) = resolve_focus_look_assist( + Vec3A::new(-0.4, 0.3, -1.0), + 1.0, + 0.75, + 0.13, + 0.18, + 0.2, + 0.15, + ); + + assert!(offset.x > 0.0); + assert!(offset.y < 0.0); + + let rotated_right = rotation.mul_vec3(Vec3::X); + assert!(rotated_right.y.abs() < 1e-5); + + let rotated_up = rotation.mul_vec3(Vec3::Y); + assert!(rotated_up.z > 0.0); + } } diff --git a/wayvrctl/src/helper.rs b/wayvrctl/src/helper.rs index 60fb91e3..9fad216a 100644 --- a/wayvrctl/src/helper.rs +++ b/wayvrctl/src/helper.rs @@ -204,3 +204,27 @@ pub async fn wlx_input_state(state: &mut WayVRClientState) { .context("failed to get input state"), ) } + +pub async fn wlx_screen_focus_toggle( + state: &mut WayVRClientState, + screen_name: String, + target_x: f32, + target_y: f32, + crop_rect: Option<[f32; 4]>, + refresh_only: bool, +) { + handle_empty_result( + WayVRClient::fn_wlx_screen_focus_toggle( + state.wayvr_client.clone(), + packet_client::WlxScreenFocusToggleParams { + screen_name, + target_x, + target_y, + crop_rect, + refresh_only, + }, + ) + .await + .context("failed to toggle screen focus"), + ) +} diff --git a/wayvrctl/src/main.rs b/wayvrctl/src/main.rs index 6df69149..683dd98f 100644 --- a/wayvrctl/src/main.rs +++ b/wayvrctl/src/main.rs @@ -14,9 +14,9 @@ use wayvr_ipc::{ }; use crate::helper::{ - WayVRClientState, wlx_device_haptics, wlx_input_state, wlx_panel_modify, wlx_show_hide, - wlx_switch_set, wvr_process_get, wvr_process_launch, wvr_process_list, wvr_process_terminate, - wvr_window_list, wvr_window_set_visible, + WayVRClientState, wlx_device_haptics, wlx_input_state, wlx_panel_modify, + wlx_screen_focus_toggle, wlx_show_hide, wlx_switch_set, wvr_process_get, wvr_process_launch, + wvr_process_list, wvr_process_terminate, wvr_window_list, wvr_window_set_visible, }; mod helper; @@ -195,6 +195,34 @@ async fn run_once(state: &mut WayVRClientState, args: Args) -> anyhow::Result<() let set = if set == 0 { None } else { Some((set - 1) as _) }; wlx_switch_set(state, set).await; } + Subcommands::ScreenFocusToggle { screen_name } => { + wlx_screen_focus_toggle(state, screen_name, 0.5, 0.5, None, false).await; + } + Subcommands::ScreenFocusAt { + screen_name, + target_x, + target_y, + crop_x, + crop_y, + crop_w, + crop_h, + refresh_only, + } => { + let crop_rect = crop_x + .zip(crop_y) + .zip(crop_w) + .zip(crop_h) + .map(|(((x, y), w), h)| [x, y, w, h]); + wlx_screen_focus_toggle( + state, + screen_name, + target_x, + target_y, + crop_rect, + refresh_only, + ) + .await; + } } Ok(()) } @@ -291,6 +319,24 @@ enum Subcommands { /// Set number to switch to, 0 to hide all sets set_or_0: usize, }, + ScreenFocusToggle { + screen_name: String, + }, + ScreenFocusAt { + screen_name: String, + target_x: f32, + target_y: f32, + #[arg(long)] + crop_x: Option, + #[arg(long)] + crop_y: Option, + #[arg(long)] + crop_w: Option, + #[arg(long)] + crop_h: Option, + #[arg(long)] + refresh_only: bool, + }, } #[derive(Debug, Clone, Copy, clap::ValueEnum)] diff --git a/wlx-common/src/config.rs b/wlx-common/src/config.rs index 8ab23297..9e0816b0 100644 --- a/wlx-common/src/config.rs +++ b/wlx-common/src/config.rs @@ -7,7 +7,10 @@ use strum::{AsRefStr, EnumProperty, EnumString, VariantArray}; use wayvr_ipc::packet_client::WvrProcessLaunchParams; use crate::{ - astr_containers::{AStrMap, AStrSet}, locale::{self}, overlays::{BackendAttribValue, ToastDisplayMethod, ToastTopic}, windowing::OverlayWindowState + astr_containers::{AStrMap, AStrSet}, + locale::{self}, + overlays::{BackendAttribValue, ToastDisplayMethod, ToastTopic}, + windowing::OverlayWindowState, }; pub type PwTokenMap = AStrMap; @@ -110,6 +113,30 @@ const fn def_point3() -> f32 { 0.3 } +const fn def_point13() -> f32 { + 0.13 +} + +const fn def_point18() -> f32 { + 0.18 +} + +const fn def_point12() -> f32 { + 0.12 +} + +const fn def_point32() -> f32 { + 0.32 +} + +const fn def_point45() -> f32 { + 0.45 +} + +const fn def_point145() -> f32 { + 1.45 +} + const fn def_osc_port() -> u16 { 9000 } @@ -138,8 +165,6 @@ const fn def_max_height() -> u16 { 1440 } - - #[derive(Deserialize, Serialize)] pub struct GeneralConfig { #[serde(default = "def_theme_path")] @@ -277,6 +302,28 @@ pub struct GeneralConfig { #[serde(default = "def_point3")] pub pointer_lerp_factor: f32, + #[serde(default = "def_point13")] + pub focused_screen_assist_x: f32, + + #[serde(default = "def_point18")] + pub focused_screen_assist_y: f32, + + #[serde(default = "def_point13")] + pub focused_screen_rotate_assist_x: f32, + + #[serde(default = "def_point12")] + pub focused_screen_rotate_assist_y: f32, + + #[serde(default = "def_point32")] + #[serde(alias = "focused_screen_curvature")] + pub focused_screen_curve_x: f32, + + #[serde(default = "def_point45")] + pub focused_screen_distance: f32, + + #[serde(default = "def_point145")] + pub focused_screen_scale: f32, + #[serde(default = "def_true")] pub space_drag_unlocked: bool, diff --git a/wlx-common/src/overlays.rs b/wlx-common/src/overlays.rs index 63490e71..763dc733 100644 --- a/wlx-common/src/overlays.rs +++ b/wlx-common/src/overlays.rs @@ -25,6 +25,7 @@ pub enum BackendAttrib { StereoFullFrame, StereoAdjustMouse, MouseTransform, + CropRect, Icon, } @@ -34,6 +35,7 @@ pub enum BackendAttribValue { StereoFullFrame(bool), StereoAdjustMouse(bool), MouseTransform(MouseTransform), + CropRect([f32; 4]), #[serde(skip_serializing, skip_deserializing)] Icon(Arc), } @@ -45,6 +47,7 @@ impl BackendAttribValue { Self::StereoFullFrame(val) => !*val, Self::StereoAdjustMouse(val) => !*val, Self::MouseTransform(val) => *val == MouseTransform::default(), + Self::CropRect(val) => *val == [0.0, 0.0, 1.0, 1.0], Self::Icon(_) => false, } }