mirror of
https://github.com/openai/codex.git
synced 2026-10-20 08:19:50 +00:00
Honor system reduced-motion preferences in the TUI (#44666)
## Why The TUI should respect the host's accessibility preference even when `tui.animations` is enabled. ## What changed - Read the system motion preference once at launch on macOS, Windows, and Linux. Changes to that preference require restarting the TUI. - Suppress animations when reduced motion is requested without changing saved configuration. Preserve configured behavior when detection is unavailable, and limit the Linux portal query to 250 ms. - Use the effective animation setting for voice controls and transcripts, and honor system reduced motion in the startup composer. ## Testing Add tests for animation preference resolution and preservation of saved settings, plus composer snapshots without sparkles at widths of 40 and 80 columns. GitOrigin-RevId: 3ea4e721ae23220a040a06123a27286a8c8f1c7c
This commit is contained in:
Generated
+2
@@ -4768,6 +4768,7 @@ dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"objc2-app-kit",
|
||||
"pathdiff",
|
||||
"pretty_assertions",
|
||||
"pulldown-cmark",
|
||||
@@ -4808,6 +4809,7 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"winsplit",
|
||||
"wiremock",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -135,6 +135,13 @@ tokio-util = { workspace = true, features = ["time"] }
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2-app-kit = { version = "0.3.2", default-features = false, features = ["std", "NSAccessibility", "NSWorkspace"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Keep the same executor features as the credential store, which also uses zbus.
|
||||
zbus = "4.4.0"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
which = { workspace = true }
|
||||
windows-sys = { version = "0.52", features = [
|
||||
@@ -145,6 +152,7 @@ windows-sys = { version = "0.52", features = [
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
winsplit = "0.1"
|
||||
|
||||
|
||||
@@ -1193,7 +1193,7 @@ impl ChatWidget {
|
||||
.wrapping_add(/*rhs*/ 1);
|
||||
self.realtime_conversation.transcript_input_generation =
|
||||
Some(self.realtime_conversation.input_generation);
|
||||
if interrupted && self.config.animations {
|
||||
if interrupted && self.local_settings.tui.animations {
|
||||
self.realtime_conversation.interruption_acknowledged_until =
|
||||
Some(Instant::now() + INTERRUPTION_ACKNOWLEDGMENT);
|
||||
}
|
||||
@@ -1273,7 +1273,7 @@ impl ChatWidget {
|
||||
&self.realtime_conversation.transcript,
|
||||
previous,
|
||||
discarded_prefix_bytes,
|
||||
MotionMode::from_animations_enabled(self.config.animations),
|
||||
MotionMode::from_animations_enabled(self.local_settings.tui.animations),
|
||||
self.frame_requester.clone(),
|
||||
);
|
||||
self.realtime_conversation.live_transcript_cell = Some(Box::new(live_cell));
|
||||
|
||||
@@ -268,7 +268,7 @@ impl ChatWidget {
|
||||
.map(|(_, speaker)| *speaker)
|
||||
.collect(),
|
||||
activity,
|
||||
animations: self.config.animations,
|
||||
animations: self.local_settings.tui.animations,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ async fn voice_composer_preserves_normal_colors_across_microphone_states() {
|
||||
assert_eq!(marker.fg, Color::Red);
|
||||
assert_ne!(marker.bg, Color::Red);
|
||||
}
|
||||
if recording && chat.config.animations {
|
||||
if recording && chat.local_settings.tui.animations {
|
||||
let rows = buffer
|
||||
.content
|
||||
.chunks(/*chunk_size*/ 47)
|
||||
@@ -159,7 +159,7 @@ async fn voice_composer_preserves_normal_colors_across_microphone_states() {
|
||||
.unwrap()
|
||||
};
|
||||
let foreground = check(&mut chat, /*recording*/ true);
|
||||
chat.config.animations = false;
|
||||
chat.local_settings.tui.animations = false;
|
||||
assert_eq!(check(&mut chat, /*recording*/ true), foreground);
|
||||
chat.realtime_conversation.microphone_muted = true;
|
||||
assert_eq!(check(&mut chat, /*recording*/ false), foreground);
|
||||
@@ -188,7 +188,7 @@ async fn voice_preserves_the_normal_composer_prompt() {
|
||||
}
|
||||
for (muted, animations) in [(true, true), (false, false), (false, true)] {
|
||||
chat.realtime_conversation.microphone_muted = muted;
|
||||
chat.config.animations = animations;
|
||||
chat.local_settings.tui.animations = animations;
|
||||
assert_eq!(prompt(&mut chat), Some('›'));
|
||||
}
|
||||
chat.thread_id = Some(ThreadId::new());
|
||||
@@ -356,7 +356,7 @@ async fn voice_footer_renders_the_main_conversation_states() {
|
||||
"Hello there",
|
||||
),
|
||||
] {
|
||||
chat.config.animations = label != "connecting";
|
||||
chat.local_settings.tui.animations = label != "connecting";
|
||||
chat.realtime_conversation.phase = phase;
|
||||
chat.realtime_conversation.microphone_muted = muted;
|
||||
chat.realtime_conversation.microphone_level = level;
|
||||
@@ -402,7 +402,7 @@ async fn voice_footer_renders_the_main_conversation_states() {
|
||||
#[tokio::test]
|
||||
async fn narrow_voice_footer_keeps_the_stop_control_before_meters() {
|
||||
let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
|
||||
chat.config.animations = true;
|
||||
chat.local_settings.tui.animations = true;
|
||||
activate_voice(&mut chat);
|
||||
chat.realtime_conversation.speaker_active_until =
|
||||
Some(std::time::Instant::now() + super::super::SPEAKER_ACTIVITY_HOLD);
|
||||
@@ -474,7 +474,7 @@ async fn narrow_voice_footer_keeps_the_stop_control_before_meters() {
|
||||
#[tokio::test]
|
||||
async fn voice_acknowledges_only_a_real_interruption_once() {
|
||||
let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
|
||||
chat.config.animations = true;
|
||||
chat.local_settings.tui.animations = true;
|
||||
activate_voice(&mut chat);
|
||||
|
||||
chat.on_realtime_transcript_delta("user".to_string(), "hello".to_string());
|
||||
@@ -503,7 +503,7 @@ async fn voice_acknowledges_only_a_real_interruption_once() {
|
||||
.is_none()
|
||||
);
|
||||
|
||||
chat.config.animations = false;
|
||||
chat.local_settings.tui.animations = false;
|
||||
chat.realtime_conversation.speaker_level = 3;
|
||||
chat.on_realtime_transcript_delta("user".to_string(), "stop".to_string());
|
||||
assert!(
|
||||
@@ -619,7 +619,7 @@ async fn clipped_voice_composer_keeps_the_draft_and_cursor_visible() {
|
||||
use ratatui::prelude::Rect;
|
||||
|
||||
let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
|
||||
chat.config.animations = false;
|
||||
chat.local_settings.tui.animations = false;
|
||||
activate_voice(&mut chat);
|
||||
chat.bottom_pane
|
||||
.set_composer_text("typed".to_string(), Vec::new(), Vec::new());
|
||||
@@ -666,7 +666,7 @@ async fn clipped_voice_composer_keeps_the_draft_and_cursor_visible() {
|
||||
#[tokio::test]
|
||||
async fn compact_voice_meters_keep_real_speaker_history_when_the_microphone_is_muted() {
|
||||
let (mut chat, _sender, _events, _ops) = make_chatwidget_manual_with_sender().await;
|
||||
chat.config.animations = false;
|
||||
chat.local_settings.tui.animations = false;
|
||||
let thread_id = activate_voice(&mut chat);
|
||||
chat.realtime_conversation.audio_meter_history = [
|
||||
(0, 255),
|
||||
|
||||
@@ -327,7 +327,7 @@ async fn captioned_voice_answer_does_not_duplicate_on_close() {
|
||||
#[tokio::test]
|
||||
async fn restored_partial_caption_accepts_late_completion_without_duplicate_history() {
|
||||
let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
|
||||
chat.config.animations = false;
|
||||
chat.local_settings.tui.animations = false;
|
||||
chat.restore_realtime_transcript_cells(VecDeque::from([
|
||||
super::super::RealtimeTranscriptRecord {
|
||||
role: "user".into(),
|
||||
|
||||
@@ -194,7 +194,7 @@ async fn transcript_deltas_track_the_current_speaker() {
|
||||
#[tokio::test]
|
||||
async fn voice_transcripts_stream_in_the_conversation_instead_of_the_footer() {
|
||||
let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
|
||||
chat.config.animations = false;
|
||||
chat.local_settings.tui.animations = false;
|
||||
activate_voice(&mut chat);
|
||||
chat.update_realtime_footer();
|
||||
|
||||
@@ -427,7 +427,7 @@ async fn direct_reset_preserves_both_partial_speakers_for_replay() {
|
||||
async fn stopping_voice_preserves_the_live_transcript_once() {
|
||||
for command in ["/voice", "/voice stop"] {
|
||||
let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
|
||||
chat.config.animations = true;
|
||||
chat.local_settings.tui.animations = true;
|
||||
activate_voice(&mut chat);
|
||||
chat.on_realtime_transcript_done("user".to_string(), "Earlier question".to_string());
|
||||
chat.on_realtime_transcript_done("assistant".to_string(), "Earlier answer".to_string());
|
||||
@@ -526,7 +526,7 @@ async fn transcript_completion_waits_for_normal_agent_stream_consolidation() {
|
||||
#[tokio::test]
|
||||
async fn transcript_handoff_moves_deferred_repeats_and_partial_once() {
|
||||
let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
|
||||
chat.config.animations = false;
|
||||
chat.local_settings.tui.animations = false;
|
||||
activate_voice(&mut chat);
|
||||
chat.on_agent_message_delta("ordinary stream".to_string());
|
||||
for _ in 0..super::super::MAX_PENDING_TRANSCRIPT_CELLS {
|
||||
@@ -699,7 +699,7 @@ async fn completed_transcript_preserves_the_other_speakers_caption() {
|
||||
#[tokio::test]
|
||||
async fn live_voice_split_flap_animates_without_changing_final_history() {
|
||||
let (mut chat, _sender, mut events, _ops) = make_chatwidget_manual_with_sender().await;
|
||||
chat.config.animations = true;
|
||||
chat.local_settings.tui.animations = true;
|
||||
activate_voice(&mut chat);
|
||||
|
||||
chat.on_realtime_transcript_delta("assistant".to_string(), "gate 73".to_string());
|
||||
@@ -749,7 +749,7 @@ async fn spoken_user_transcript_preserves_red_chevron_and_canonical_history() {
|
||||
.add_modifier
|
||||
.contains(ratatui::style::Modifier::BOLD)
|
||||
);
|
||||
chat.config.animations = false;
|
||||
chat.local_settings.tui.animations = false;
|
||||
chat.on_realtime_transcript_delta("user".to_string(), " world".to_string());
|
||||
assert!(
|
||||
chat.realtime_conversation
|
||||
|
||||
@@ -14,7 +14,7 @@ use serial_test::serial;
|
||||
#[tokio::test]
|
||||
async fn voice_live_transcript_renders_beside_the_streamed_cell() {
|
||||
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.config.animations = false;
|
||||
chat.local_settings.tui.animations = false;
|
||||
activate_voice_for_thread(&mut chat, ThreadId::new());
|
||||
chat.transcript.active_cell = Some(Box::new(history_cell::StreamingAgentTailCell::new(
|
||||
vec![Line::from("Agent answer arriving").into()],
|
||||
|
||||
@@ -204,6 +204,7 @@ mod status;
|
||||
mod status_indicator_widget;
|
||||
mod streaming;
|
||||
mod style;
|
||||
mod system_motion;
|
||||
mod task_mentions;
|
||||
mod temporary_structured_request;
|
||||
mod terminal_hyperlinks;
|
||||
@@ -1011,6 +1012,7 @@ pub async fn run_main(
|
||||
loader_overrides: LoaderOverrides,
|
||||
explicit_remote_endpoint: Option<RemoteAppServerEndpoint>,
|
||||
) -> std::io::Result<AppExitInfo> {
|
||||
system_motion::initialize().await;
|
||||
match startup_orchestration::run_main_inner(
|
||||
cli,
|
||||
arg0_paths,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! The resolved core config is a temporary input at local load/reload boundaries. Server thread
|
||||
//! responses must never refresh these values; live preference changes belong here. The remaining
|
||||
//! Config-based lifecycle adapters also use this conversion until their interfaces are migrated.
|
||||
//! Effective animations also respect the TUI host's launch-time accessibility preference.
|
||||
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::legacy_core::config::TerminalResizeReflowConfig;
|
||||
@@ -23,10 +24,17 @@ pub(crate) struct LocalSettings {
|
||||
|
||||
impl From<&Config> for LocalSettings {
|
||||
fn from(config: &Config) -> Self {
|
||||
Self::with_system_motion(config, crate::system_motion::mode())
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalSettings {
|
||||
fn with_system_motion(config: &Config, system_motion: crate::motion::MotionMode) -> Self {
|
||||
Self {
|
||||
tui: Tui {
|
||||
notification_settings: config.tui_notifications.clone(),
|
||||
animations: config.animations,
|
||||
animations: config.animations
|
||||
&& system_motion == crate::motion::MotionMode::Animated,
|
||||
whimsy: config.tui_whimsy,
|
||||
show_tooltips: config.show_tooltips,
|
||||
show_server_version_notice: config.tui_show_server_version_notice,
|
||||
|
||||
@@ -5,6 +5,104 @@ use codex_config::LoaderOverrides;
|
||||
use codex_config::types::SessionPickerViewMode;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_reduced_motion_renders_astra_composer_without_sparkles() -> anyhow::Result<()> {
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::BottomPane;
|
||||
use crate::bottom_pane::BottomPaneParams;
|
||||
use crate::motion::MotionMode;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::tui::FrameRequester;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
let home = tempfile::tempdir()?;
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.loader_overrides(LoaderOverrides {
|
||||
ignore_project_config: true,
|
||||
..LoaderOverrides::without_managed_config_for_tests()
|
||||
})
|
||||
.build()
|
||||
.await?;
|
||||
let settings = LocalSettings::with_system_motion(&config, MotionMode::Reduced);
|
||||
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: AppEventSender::new(tx),
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".into(),
|
||||
disable_paste_burst: true,
|
||||
animations_enabled: settings.tui.animations,
|
||||
skills: None,
|
||||
});
|
||||
pane.set_astra_sparkle("astra", &settings.tui);
|
||||
pane.set_composer_text("Explore the night sky".into(), Vec::new(), Vec::new());
|
||||
crate::terminal_palette::with_test_default_colors(
|
||||
crate::terminal_probe::DefaultColors {
|
||||
fg: (230, 216, 255),
|
||||
bg: (36, 27, 53),
|
||||
},
|
||||
|| {
|
||||
for width in [40, 80] {
|
||||
let area = Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
width,
|
||||
pane.desired_height(width),
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
pane.render(area, &mut buffer);
|
||||
let rows = buffer
|
||||
.content
|
||||
.chunks(usize::from(width))
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(ratatui::buffer::Cell::symbol)
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
insta::assert_snapshot!(format!("system_reduced_motion_astra_{width}"), rows);
|
||||
}
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_motion_suppresses_animations_without_changing_saved_preferences()
|
||||
-> anyhow::Result<()> {
|
||||
use crate::motion::MotionMode;
|
||||
|
||||
for configured in [true, false] {
|
||||
let home = tempfile::tempdir()?;
|
||||
let config_text = format!("[tui]\nanimations = {configured}\nwhimsy = true\n");
|
||||
std::fs::write(home.path().join("config.toml"), &config_text)?;
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.loader_overrides(LoaderOverrides {
|
||||
ignore_project_config: true,
|
||||
..LoaderOverrides::without_managed_config_for_tests()
|
||||
})
|
||||
.build()
|
||||
.await?;
|
||||
let animated = LocalSettings::with_system_motion(&config, MotionMode::Animated);
|
||||
let reduced = LocalSettings::with_system_motion(&config, MotionMode::Reduced);
|
||||
let mut expected = animated.clone();
|
||||
expected.tui.animations = false;
|
||||
assert_eq!(reduced, expected);
|
||||
assert_eq!(animated.tui.animations, configured);
|
||||
assert_eq!(config.animations, configured);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(home.path().join("config.toml"))?,
|
||||
config_text
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_load_preserves_defaults_and_resolved_overrides() -> anyhow::Result<()> {
|
||||
for config_text in [
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/local_settings_tests.rs
|
||||
assertion_line: 57
|
||||
expression: rows
|
||||
---
|
||||
|
||||
› Explore the night sky
|
||||
|
||||
100% context left
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/local_settings_tests.rs
|
||||
assertion_line: 57
|
||||
expression: rows
|
||||
---
|
||||
|
||||
› Explore the night sky
|
||||
|
||||
100% context left
|
||||
@@ -514,7 +514,7 @@ fn startup_draft_bottom_pane(
|
||||
enhanced_keys_supported,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
animations_enabled: true,
|
||||
animations_enabled: crate::system_motion::mode() == crate::motion::MotionMode::Animated,
|
||||
skills: None,
|
||||
},
|
||||
ChatComposerConfig::plain_text(),
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Reads the TUI host's accessibility preference once at launch.
|
||||
//!
|
||||
//! Detection never changes persisted configuration or consults the app server. An unavailable
|
||||
//! preference preserves configured behavior; an explicit request for less motion suppresses all
|
||||
//! effects governed by `tui.animations`. Restart the TUI after changing the OS preference.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::motion::MotionMode;
|
||||
|
||||
static SYSTEM_MOTION: OnceLock<MotionMode> = OnceLock::new();
|
||||
|
||||
pub(crate) async fn initialize() {
|
||||
let mode = detect().await.unwrap_or(MotionMode::Animated);
|
||||
let _ = SYSTEM_MOTION.set(mode);
|
||||
}
|
||||
|
||||
pub(crate) fn mode() -> MotionMode {
|
||||
SYSTEM_MOTION.get().copied().unwrap_or(MotionMode::Animated)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn detect() -> Option<MotionMode> {
|
||||
let workspace = objc2_app_kit::NSWorkspace::sharedWorkspace();
|
||||
Some(if workspace.accessibilityDisplayShouldReduceMotion() {
|
||||
MotionMode::Reduced
|
||||
} else {
|
||||
MotionMode::Animated
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn detect() -> Option<MotionMode> {
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::SPI_GETCLIENTAREAANIMATION;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::SystemParametersInfoW;
|
||||
|
||||
let mut enabled: windows_sys::Win32::Foundation::BOOL = 0;
|
||||
// SAFETY: SPI_GETCLIENTAREAANIMATION writes one BOOL to this valid, aligned pointer.
|
||||
let success = unsafe {
|
||||
SystemParametersInfoW(
|
||||
SPI_GETCLIENTAREAANIMATION,
|
||||
/*uiparam*/ 0,
|
||||
(&mut enabled as *mut windows_sys::Win32::Foundation::BOOL).cast(),
|
||||
/*fwinini*/ 0,
|
||||
)
|
||||
};
|
||||
(success != 0).then(|| MotionMode::from_animations_enabled(enabled != 0))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn detect() -> Option<MotionMode> {
|
||||
// A missing or unresponsive session bus must not hold up terminal startup.
|
||||
tokio::time::timeout(std::time::Duration::from_millis(/*millis*/ 250), async {
|
||||
let connection = zbus::Connection::session().await.ok()?;
|
||||
let proxy = zbus::Proxy::new(
|
||||
&connection,
|
||||
"org.freedesktop.portal.Desktop",
|
||||
"/org/freedesktop/portal/desktop",
|
||||
"org.freedesktop.portal.Settings",
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
let value: zbus::zvariant::OwnedValue = proxy
|
||||
.call("ReadOne", &("org.freedesktop.appearance", "reduced-motion"))
|
||||
.await
|
||||
.ok()?;
|
||||
let value = u32::try_from(value).ok()?;
|
||||
// The portal specifies that unknown values mean no preference.
|
||||
Some(if value == 1 {
|
||||
MotionMode::Reduced
|
||||
} else {
|
||||
MotionMode::Animated
|
||||
})
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
async fn detect() -> Option<MotionMode> {
|
||||
None
|
||||
}
|
||||
Reference in New Issue
Block a user