Initial commit: pure-Rust cross-platform Insta360 Link controller suite
This commit is contained in:
27 files changed
+22165
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,500 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use egui::{ColorImage, Pos2, TextureHandle};
|
||||
pub use insta360_link_protocol::*;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
/// UI mode matching `Webcam.Controller.curUIMode` (`MainWindow` vs `Toolbar`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UiMode {
|
||||
MainWindow,
|
||||
Toolbar,
|
||||
}
|
||||
|
||||
/// Right sidebar tab matching `Settings.qml` (`View`, `Effects`, `More`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RightSidebarTab {
|
||||
View,
|
||||
Effects,
|
||||
More,
|
||||
}
|
||||
|
||||
/// Active popup in Compact Toolbar Mode (`Toolbar.qml`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolbarPopup {
|
||||
None,
|
||||
PtzPanel,
|
||||
TrackMode,
|
||||
VirtualBackground,
|
||||
PictureSetting,
|
||||
GestureCtrl,
|
||||
RemoteCtrl,
|
||||
MoreSetting,
|
||||
}
|
||||
|
||||
/// Active tab in the Preferences modal (`preference/MoreSettings.qml`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PreferenceTab {
|
||||
General,
|
||||
Hotkeys,
|
||||
About,
|
||||
GetLog,
|
||||
}
|
||||
|
||||
/// Virtual background mode (`ImageBackground.qml`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BackgroundEffectMode {
|
||||
None,
|
||||
StandardBlur,
|
||||
NaturalBokeh,
|
||||
CustomImage(usize),
|
||||
}
|
||||
|
||||
/// Resolution & frame rate preset (`DefFormatCombo.qml`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VideoFormatPreset {
|
||||
Uhd4k30,
|
||||
Fhd1080p60,
|
||||
Fhd1080p30,
|
||||
Hd720p60,
|
||||
Hd720p30,
|
||||
Sd360p30,
|
||||
Vertical1080x1920p30,
|
||||
}
|
||||
|
||||
impl VideoFormatPreset {
|
||||
pub const ALL: [VideoFormatPreset; 7] = [
|
||||
Self::Uhd4k30,
|
||||
Self::Fhd1080p60,
|
||||
Self::Fhd1080p30,
|
||||
Self::Hd720p60,
|
||||
Self::Hd720p30,
|
||||
Self::Sd360p30,
|
||||
Self::Vertical1080x1920p30,
|
||||
];
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Uhd4k30 => "4K 30fps",
|
||||
Self::Fhd1080p60 => "1080p 60fps",
|
||||
Self::Fhd1080p30 => "1080p 30fps",
|
||||
Self::Hd720p60 => "720p 60fps",
|
||||
Self::Hd720p30 => "720p 30fps",
|
||||
Self::Sd360p30 => "360p 30fps",
|
||||
Self::Vertical1080x1920p30 => "1080x1920 30fps",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dimensions(&self) -> (u32, u32) {
|
||||
match self {
|
||||
Self::Uhd4k30 => (3840, 2160),
|
||||
Self::Fhd1080p60 | Self::Fhd1080p30 => (1920, 1080),
|
||||
Self::Hd720p60 | Self::Hd720p30 => (1280, 720),
|
||||
Self::Sd360p30 => (640, 360),
|
||||
Self::Vertical1080x1920p30 => (1088, 1920),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fps(&self) -> u32 {
|
||||
match self {
|
||||
Self::Fhd1080p60 | Self::Hd720p60 => 60,
|
||||
_ => 30,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_4k(&self) -> bool {
|
||||
matches!(self, Self::Uhd4k30)
|
||||
}
|
||||
|
||||
pub fn is_vertical(&self) -> bool {
|
||||
matches!(self, Self::Vertical1080x1920p30)
|
||||
}
|
||||
}
|
||||
|
||||
/// Saved PTZ preset item (`PTZControl.qml` repeater item).
|
||||
#[derive(Clone)]
|
||||
pub struct PtzPresetSlot {
|
||||
pub id: u8,
|
||||
pub name: String,
|
||||
pub saved: bool,
|
||||
pub pan_deg: f32,
|
||||
pub tilt_deg: f32,
|
||||
pub zoom_x: f32,
|
||||
pub host_x: f32,
|
||||
pub host_y: f32,
|
||||
pub mode: VideoMode,
|
||||
pub thumbnail: Option<TextureHandle>,
|
||||
pub raw_thumb: Option<ColorImage>,
|
||||
pub renaming: bool,
|
||||
}
|
||||
|
||||
/// Saved Color Preset item (`ImageScreen.qml` template repeater item).
|
||||
#[derive(Clone)]
|
||||
pub struct ColorPresetSlot {
|
||||
pub id: i8,
|
||||
pub name: String,
|
||||
pub saved: bool,
|
||||
pub brightness: i16,
|
||||
pub contrast: u16,
|
||||
pub saturation: u16,
|
||||
pub sharpness: u16,
|
||||
pub wb_auto: bool,
|
||||
pub wb_kelvin: u16,
|
||||
pub auto_exposure: bool,
|
||||
pub ev_comp: f32,
|
||||
pub iso: u16,
|
||||
pub shutter: u32,
|
||||
pub hdr: bool,
|
||||
pub thumbnail: Option<TextureHandle>,
|
||||
pub raw_thumb: Option<ColorImage>,
|
||||
pub renaming: bool,
|
||||
}
|
||||
|
||||
/// ISO discrete step values matching `Webcam.Controller.camera.isoList` (`ImageScreen.qml`).
|
||||
pub const ISO_LIST: [u16; 16] = [
|
||||
100, 125, 160, 200, 250, 320, 400, 500, 640, 800, 1000, 1250, 1600, 2000, 2500, 3200,
|
||||
];
|
||||
|
||||
/// Shutter speed denominator list (`1/N` seconds) matching `Webcam.Controller.camera.shutterList`.
|
||||
pub const SHUTTER_DENOM_LIST: [u32; 20] = [
|
||||
30, 40, 50, 60, 80, 100, 120, 160, 200, 240, 320, 400, 500, 640, 800, 1000, 1600, 2000, 4000,
|
||||
8000,
|
||||
];
|
||||
|
||||
/// Shared UI & camera state used across `main.rs`, `right_panel.rs`, and `video_engine.rs`.
|
||||
pub struct CameraAppState {
|
||||
// Theme & window mode
|
||||
pub is_light_mode: bool,
|
||||
pub ui_mode: UiMode,
|
||||
pub right_tab: RightSidebarTab,
|
||||
pub toolbar_popup: ToolbarPopup,
|
||||
pub show_preferences: bool,
|
||||
pub preference_tab: PreferenceTab,
|
||||
pub show_qr_dialog: bool,
|
||||
pub show_virtual_cam_pop: bool,
|
||||
pub show_camera_log_pop: bool,
|
||||
pub show_factory_reset_pop: bool,
|
||||
pub show_pause_track_overlay: bool,
|
||||
pub show_deskview_pitch_pop: bool,
|
||||
|
||||
// Preview & recording state
|
||||
pub preview_open: bool,
|
||||
pub virtual_camera_enabled: bool,
|
||||
pub format_preset: VideoFormatPreset,
|
||||
pub is_recording: bool,
|
||||
pub recording_started: Option<Instant>,
|
||||
pub screenshot_dir: PathBuf,
|
||||
pub recording_dir: PathBuf,
|
||||
pub toast_message: Option<(String, Instant, bool)>,
|
||||
|
||||
// Device Info & Status
|
||||
pub device_info: DeviceInfo,
|
||||
pub ptz_info: PtzVersionInfo,
|
||||
pub device_status: DeviceStatus,
|
||||
|
||||
// Gimbal / View / PTZ state
|
||||
pub pan_deg: f32,
|
||||
pub tilt_deg: f32,
|
||||
pub zoom_x: f32, // 1.0 .. 4.0
|
||||
pub roll_deg: f32, // -1.0 .. +1.0 (tenths of degree in hardware)
|
||||
pub deskview_pitch: i16, // -900 .. 0
|
||||
pub host_ptz_x: f32, // 0.0 .. 1.0 normalized center X (Link 2C)
|
||||
pub host_ptz_y: f32, // 0.0 .. 1.0 normalized center Y (Link 2C)
|
||||
pub bias_x: i16,
|
||||
pub bias_y: i16,
|
||||
pub ptz_presets: Vec<PtzPresetSlot>,
|
||||
pub active_preset_id: Option<u8>,
|
||||
pub open_more_positions: bool,
|
||||
|
||||
// Video Mode & Tracking state
|
||||
pub video_mode: VideoMode,
|
||||
pub default_track_mode: u8, // 0 = Single Tracking, 1 = Group Tracking (Link 2)
|
||||
pub ai_zoom_enabled: bool, // Smart Composition switch
|
||||
pub composition_style: CompositionStyle,
|
||||
pub track_speed: TrackSpeed,
|
||||
pub enable_auto_tracking: bool,
|
||||
pub enable_single_tap_tracking: bool,
|
||||
pub pause_track_enabled: bool,
|
||||
pub forbidden_areas: [ForbiddenAreaInfo; 4],
|
||||
pub tracked_targets: Vec<UvcRect>,
|
||||
pub selected_target_id: i16,
|
||||
pub whiteboard_corners: [Pos2; 4], // normalized [0..1] corners for Smart Whiteboard
|
||||
|
||||
// Color & Exposure state (`ImageScreen.qml`)
|
||||
pub auto_exposure: bool,
|
||||
pub ev_compensation: f32, // -3.0 .. +3.0
|
||||
pub iso_index: usize, // index into ISO_LIST
|
||||
pub shutter_index: usize, // index into SHUTTER_DENOM_LIST
|
||||
pub spline_points: Vec<(f32, f32)>, // normalized (0..1, 0..1) control points for DefSplineCanvas
|
||||
pub auto_focus: bool,
|
||||
pub manual_focus: u16, // 0..100
|
||||
pub auto_white_balance: bool,
|
||||
pub wb_kelvin: u16, // 2000..10000
|
||||
pub open_more_color: bool, // Show/Collapse in Color section
|
||||
pub brightness: i16, // 0..100
|
||||
pub contrast: u16, // 0..100
|
||||
pub saturation: u16, // 0..100
|
||||
pub sharpness: u16, // 0..100
|
||||
pub hdr_enabled: bool,
|
||||
pub anti_flicker: PowerLineFrequency,
|
||||
pub startup_params_enabled: bool,
|
||||
pub color_presets: Vec<ColorPresetSlot>,
|
||||
pub active_color_preset_id: Option<i8>,
|
||||
pub open_more_color_presets: bool,
|
||||
|
||||
// Background, Makeup & Filter state (`ImageBackground.qml`, `ImageBeauty.qml`, `ImageFilter.qml`)
|
||||
pub background_mode: BackgroundEffectMode,
|
||||
pub blur_intensity: u8, // 0..100
|
||||
pub custom_backgrounds: Vec<String>,
|
||||
pub makeup_enabled: bool,
|
||||
pub makeup_intensity: u8, // 0..100
|
||||
pub filter_dropdown_open: bool,
|
||||
pub filter_mode: FilterMode,
|
||||
|
||||
// More Tab state (`MoreSettings.qml`)
|
||||
pub enable_all_gestures: bool,
|
||||
pub use_gesture_palm: bool,
|
||||
pub use_gesture_v: bool,
|
||||
pub use_gesture_l: bool,
|
||||
pub use_gesture_rock: bool,
|
||||
pub use_gesture_ok: bool,
|
||||
pub audio_capture_mode: AudioCaptureMode,
|
||||
pub audio_denoise: bool,
|
||||
pub horizontal_mirror: bool,
|
||||
pub vertical_mirror: bool,
|
||||
pub smart_adjustment: bool,
|
||||
pub horizontal_correction: bool,
|
||||
pub ultimate_privacy: bool,
|
||||
pub privacy_sleep: bool,
|
||||
pub vertical_resolution_mode: bool,
|
||||
pub forced_vertical_lock: bool,
|
||||
pub low_resolution_mode: bool,
|
||||
|
||||
// Real-time hardware sync, continuous PTZ velocity, and AI target polling
|
||||
pub stream_initialized: bool,
|
||||
pub joystick_dragging: bool,
|
||||
pub last_ptz_cmd: (u8, i8, u8, i8),
|
||||
pub last_ptz_send_time: Option<Instant>,
|
||||
pub nudge_stop_at: Option<Instant>,
|
||||
pub pending_ptz_restore: Option<(Instant, i32, i32)>,
|
||||
pub ready_auto_track: bool,
|
||||
pub last_track_poll: Option<Instant>,
|
||||
pub last_hw_sync: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Default for CameraAppState {
|
||||
fn default() -> Self {
|
||||
let mut ptz_presets = Vec::with_capacity(6);
|
||||
for i in 0..6u8 {
|
||||
ptz_presets.push(PtzPresetSlot {
|
||||
id: i,
|
||||
name: format!("Preset {}", i + 1),
|
||||
saved: i < 2,
|
||||
pan_deg: if i == 0 { 0.0 } else { 22.0 },
|
||||
tilt_deg: if i == 0 { 0.0 } else { -8.0 },
|
||||
zoom_x: if i == 0 { 1.0 } else { 1.5 },
|
||||
host_x: 0.5,
|
||||
host_y: 0.5,
|
||||
mode: VideoMode::Normal,
|
||||
thumbnail: None,
|
||||
raw_thumb: None,
|
||||
renaming: false,
|
||||
});
|
||||
}
|
||||
|
||||
let color_presets = vec![ColorPresetSlot {
|
||||
id: 1,
|
||||
name: "Studio Warm".to_string(),
|
||||
saved: true,
|
||||
brightness: 54,
|
||||
contrast: 52,
|
||||
saturation: 56,
|
||||
sharpness: 50,
|
||||
wb_auto: false,
|
||||
wb_kelvin: 5600,
|
||||
auto_exposure: true,
|
||||
ev_comp: 0.3,
|
||||
iso: 100,
|
||||
shutter: 120,
|
||||
hdr: false,
|
||||
thumbnail: None,
|
||||
raw_thumb: None,
|
||||
renaming: false,
|
||||
}];
|
||||
|
||||
let home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("."));
|
||||
|
||||
Self {
|
||||
is_light_mode: false,
|
||||
ui_mode: UiMode::MainWindow,
|
||||
right_tab: RightSidebarTab::View,
|
||||
toolbar_popup: ToolbarPopup::None,
|
||||
show_preferences: false,
|
||||
preference_tab: PreferenceTab::General,
|
||||
show_qr_dialog: false,
|
||||
show_virtual_cam_pop: false,
|
||||
show_camera_log_pop: false,
|
||||
show_factory_reset_pop: false,
|
||||
show_pause_track_overlay: false,
|
||||
show_deskview_pitch_pop: false,
|
||||
|
||||
preview_open: true,
|
||||
virtual_camera_enabled: false,
|
||||
format_preset: VideoFormatPreset::Fhd1080p30,
|
||||
is_recording: false,
|
||||
recording_started: None,
|
||||
screenshot_dir: home.join("Pictures"),
|
||||
recording_dir: home.join("Movies"),
|
||||
toast_message: None,
|
||||
|
||||
device_info: DeviceInfo {
|
||||
device_name: "Insta360 Link 2".to_string(),
|
||||
firmware_version: "v2.5.8.8_build1".to_string(),
|
||||
serial_number: "INSTA360SIM0001".to_string(),
|
||||
},
|
||||
ptz_info: PtzVersionInfo {
|
||||
version: "v1.3.1.0_build0".to_string(),
|
||||
pan: 0,
|
||||
tilt: 0,
|
||||
zoom: 100,
|
||||
status: 0,
|
||||
},
|
||||
device_status: DeviceStatus::empty(),
|
||||
|
||||
pan_deg: 0.0,
|
||||
tilt_deg: 0.0,
|
||||
zoom_x: 1.0,
|
||||
roll_deg: 0.0,
|
||||
deskview_pitch: 0,
|
||||
host_ptz_x: 0.5,
|
||||
host_ptz_y: 0.5,
|
||||
bias_x: 0,
|
||||
bias_y: 0,
|
||||
ptz_presets,
|
||||
active_preset_id: Some(0),
|
||||
open_more_positions: false,
|
||||
|
||||
video_mode: VideoMode::Normal,
|
||||
default_track_mode: 0,
|
||||
ai_zoom_enabled: true,
|
||||
composition_style: CompositionStyle::HalfBody,
|
||||
track_speed: TrackSpeed::Normal,
|
||||
enable_auto_tracking: true,
|
||||
enable_single_tap_tracking: true,
|
||||
pause_track_enabled: false,
|
||||
forbidden_areas: [
|
||||
ForbiddenAreaInfo {
|
||||
enabled: false,
|
||||
index: 0,
|
||||
pan_min: -350,
|
||||
pan_max: -150,
|
||||
tilt_min: -200,
|
||||
tilt_max: 200,
|
||||
},
|
||||
ForbiddenAreaInfo {
|
||||
enabled: false,
|
||||
index: 1,
|
||||
pan_min: 150,
|
||||
pan_max: 350,
|
||||
tilt_min: -200,
|
||||
tilt_max: 200,
|
||||
},
|
||||
ForbiddenAreaInfo {
|
||||
enabled: false,
|
||||
index: 2,
|
||||
pan_min: -200,
|
||||
pan_max: 200,
|
||||
tilt_min: 100,
|
||||
tilt_max: 300,
|
||||
},
|
||||
ForbiddenAreaInfo {
|
||||
enabled: false,
|
||||
index: 3,
|
||||
pan_min: -200,
|
||||
pan_max: 200,
|
||||
tilt_min: -300,
|
||||
tilt_max: -100,
|
||||
},
|
||||
],
|
||||
tracked_targets: Vec::new(),
|
||||
selected_target_id: 0,
|
||||
whiteboard_corners: [
|
||||
Pos2::new(0.18, 0.16),
|
||||
Pos2::new(0.82, 0.16),
|
||||
Pos2::new(0.82, 0.78),
|
||||
Pos2::new(0.18, 0.78),
|
||||
],
|
||||
|
||||
auto_exposure: true,
|
||||
ev_compensation: 0.0,
|
||||
iso_index: 0,
|
||||
shutter_index: 5, // 1/100s
|
||||
spline_points: vec![(0.0, 0.0), (0.33, 0.33), (0.67, 0.67), (1.0, 1.0)],
|
||||
auto_focus: true,
|
||||
manual_focus: 50,
|
||||
auto_white_balance: true,
|
||||
wb_kelvin: 5000,
|
||||
open_more_color: false,
|
||||
brightness: 50,
|
||||
contrast: 50,
|
||||
saturation: 50,
|
||||
sharpness: 50,
|
||||
hdr_enabled: false,
|
||||
anti_flicker: PowerLineFrequency::Auto,
|
||||
startup_params_enabled: false,
|
||||
color_presets,
|
||||
active_color_preset_id: None,
|
||||
open_more_color_presets: false,
|
||||
|
||||
background_mode: BackgroundEffectMode::None,
|
||||
blur_intensity: 60,
|
||||
custom_backgrounds: vec![
|
||||
"Modern Office".to_string(),
|
||||
"Minimalist Studio".to_string(),
|
||||
"Warm Bookshelf".to_string(),
|
||||
],
|
||||
makeup_enabled: false,
|
||||
makeup_intensity: 45,
|
||||
filter_dropdown_open: true,
|
||||
filter_mode: FilterMode::None,
|
||||
|
||||
enable_all_gestures: true,
|
||||
use_gesture_palm: true,
|
||||
use_gesture_v: true,
|
||||
use_gesture_l: true,
|
||||
use_gesture_rock: false,
|
||||
use_gesture_ok: false,
|
||||
audio_capture_mode: AudioCaptureMode::VoiceFocus,
|
||||
audio_denoise: true,
|
||||
horizontal_mirror: false,
|
||||
vertical_mirror: false,
|
||||
smart_adjustment: false,
|
||||
horizontal_correction: false,
|
||||
ultimate_privacy: false,
|
||||
privacy_sleep: false,
|
||||
vertical_resolution_mode: false,
|
||||
forced_vertical_lock: false,
|
||||
low_resolution_mode: false,
|
||||
|
||||
stream_initialized: false,
|
||||
joystick_dragging: false,
|
||||
last_ptz_cmd: (0, 0, 0, 0),
|
||||
last_ptz_send_time: None,
|
||||
nudge_stop_at: None,
|
||||
pending_ptz_restore: None,
|
||||
ready_auto_track: false,
|
||||
last_track_poll: None,
|
||||
last_hw_sync: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CameraAppState {
|
||||
pub fn show_toast(&mut self, msg: impl Into<String>, is_error: bool) {
|
||||
self.toast_message = Some((msg.into(), Instant::now(), is_error));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,934 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use egui::{
|
||||
Align, Align2, Button, Color32, Context, FontId, Frame, Layout, Margin, Pos2, Rect, Response,
|
||||
RichText, Rounding, Sense, Stroke, Style, Ui, Vec2, Visuals, WidgetInfo, WidgetType,
|
||||
};
|
||||
|
||||
/// Official Insta360 Link Controller v2.0.2 theme palette (`InsUI.qml`).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct InsTheme {
|
||||
pub is_light_mode: bool,
|
||||
pub brand_green: Color32,
|
||||
pub brand_green_lighter: Color32,
|
||||
pub brand_light: Color32,
|
||||
pub brand_text: Color32,
|
||||
pub bg01: Color32,
|
||||
pub bg02: Color32,
|
||||
pub bg03: Color32,
|
||||
pub bg04: Color32,
|
||||
pub bg05: Color32,
|
||||
pub border01: Color32,
|
||||
pub border02: Color32,
|
||||
pub border03: Color32,
|
||||
pub text01: Color32,
|
||||
pub text02: Color32,
|
||||
pub text03: Color32,
|
||||
pub text04: Color32,
|
||||
pub overlay01: Color32,
|
||||
pub overlay02: Color32,
|
||||
pub overlay03: Color32,
|
||||
pub mask01: Color32,
|
||||
pub error: Color32,
|
||||
pub success: Color32,
|
||||
pub warning: Color32,
|
||||
}
|
||||
|
||||
impl InsTheme {
|
||||
pub fn new(is_light_mode: bool) -> Self {
|
||||
if is_light_mode {
|
||||
Self {
|
||||
is_light_mode: true,
|
||||
brand_green: Color32::from_rgb(18, 219, 139),
|
||||
brand_green_lighter: Color32::from_rgb(81, 245, 179),
|
||||
brand_light: Color32::from_rgb(240, 255, 249),
|
||||
brand_text: Color32::from_rgb(0, 178, 107),
|
||||
bg01: Color32::from_rgb(229, 229, 229),
|
||||
bg02: Color32::from_rgb(240, 240, 240),
|
||||
bg03: Color32::from_rgb(250, 250, 250),
|
||||
bg04: Color32::from_rgb(255, 255, 255),
|
||||
bg05: Color32::from_rgb(255, 255, 255),
|
||||
border01: Color32::from_rgba_unmultiplied(0, 0, 0, 26),
|
||||
border02: Color32::from_rgba_unmultiplied(0, 0, 0, 46),
|
||||
border03: Color32::from_rgba_unmultiplied(0, 0, 0, 66),
|
||||
text01: Color32::from_rgb(41, 41, 41),
|
||||
text02: Color32::from_rgb(82, 82, 82),
|
||||
text03: Color32::from_rgb(153, 153, 153),
|
||||
text04: Color32::from_rgb(191, 191, 191),
|
||||
overlay01: Color32::from_rgba_unmultiplied(255, 255, 255, 255),
|
||||
overlay02: Color32::from_rgba_unmultiplied(255, 255, 255, 255),
|
||||
overlay03: Color32::from_rgba_unmultiplied(255, 255, 255, 255),
|
||||
mask01: Color32::from_rgba_unmultiplied(0, 0, 0, 153),
|
||||
error: Color32::from_rgb(255, 64, 77),
|
||||
success: Color32::from_rgb(0, 209, 112),
|
||||
warning: Color32::from_rgb(245, 217, 37),
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
is_light_mode: false,
|
||||
brand_green: Color32::from_rgb(42, 235, 158),
|
||||
brand_green_lighter: Color32::from_rgb(26, 255, 163),
|
||||
brand_light: Color32::from_rgb(47, 51, 49),
|
||||
brand_text: Color32::from_rgb(15, 184, 116),
|
||||
bg01: Color32::from_rgb(33, 33, 33),
|
||||
bg02: Color32::from_rgb(43, 43, 43),
|
||||
bg03: Color32::from_rgb(51, 51, 51),
|
||||
bg04: Color32::from_rgb(56, 56, 56),
|
||||
bg05: Color32::from_rgb(71, 71, 71),
|
||||
border01: Color32::from_rgba_unmultiplied(255, 255, 255, 20),
|
||||
border02: Color32::from_rgba_unmultiplied(255, 255, 255, 36),
|
||||
border03: Color32::from_rgba_unmultiplied(255, 255, 255, 51),
|
||||
text01: Color32::from_rgb(235, 235, 235),
|
||||
text02: Color32::from_rgb(184, 184, 184),
|
||||
text03: Color32::from_rgb(138, 138, 138),
|
||||
text04: Color32::from_rgb(89, 89, 89),
|
||||
overlay01: Color32::from_rgba_unmultiplied(216, 231, 245, 15),
|
||||
overlay02: Color32::from_rgba_unmultiplied(235, 245, 255, 26),
|
||||
overlay03: Color32::from_rgba_unmultiplied(235, 245, 255, 38),
|
||||
mask01: Color32::from_rgba_unmultiplied(0, 0, 0, 153),
|
||||
error: Color32::from_rgb(255, 84, 96),
|
||||
success: Color32::from_rgb(0, 209, 49),
|
||||
warning: Color32::from_rgb(249, 232, 124),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InsTheme {
|
||||
fn default() -> Self {
|
||||
Self::new(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures `egui::Style` and `egui::Visuals` with the official Insta360 Link Controller palette
|
||||
/// (`bg01`, `bg02`, `bg03`, `brand_green` accents) and returns the configured `InsTheme`.
|
||||
pub fn apply_ins_theme(ctx: &Context, is_light_mode: bool) -> InsTheme {
|
||||
let theme = InsTheme::new(is_light_mode);
|
||||
let mut style: Style = (*ctx.style()).clone();
|
||||
let mut visuals = if is_light_mode {
|
||||
Visuals::light()
|
||||
} else {
|
||||
Visuals::dark()
|
||||
};
|
||||
|
||||
visuals.panel_fill = theme.bg01;
|
||||
visuals.window_fill = theme.bg02;
|
||||
visuals.extreme_bg_color = theme.bg01;
|
||||
visuals.faint_bg_color = theme.bg03;
|
||||
|
||||
visuals.selection.bg_fill = theme.brand_green;
|
||||
visuals.selection.stroke = Stroke::new(
|
||||
1.0_f32,
|
||||
if is_light_mode {
|
||||
Color32::WHITE
|
||||
} else {
|
||||
theme.bg01
|
||||
},
|
||||
);
|
||||
visuals.hyperlink_color = theme.brand_green;
|
||||
|
||||
let r6 = Rounding::same(6.0_f32);
|
||||
let r8 = Rounding::same(8.0_f32);
|
||||
|
||||
visuals.widgets.noninteractive.bg_fill = theme.bg02;
|
||||
visuals.widgets.noninteractive.weak_bg_fill = theme.bg02;
|
||||
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0_f32, theme.border01);
|
||||
visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, theme.text01);
|
||||
visuals.widgets.noninteractive.rounding = r8;
|
||||
|
||||
visuals.widgets.inactive.bg_fill = theme.bg03;
|
||||
visuals.widgets.inactive.weak_bg_fill = theme.bg03;
|
||||
visuals.widgets.inactive.bg_stroke = Stroke::new(1.0_f32, theme.border01);
|
||||
visuals.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, theme.text02);
|
||||
visuals.widgets.inactive.rounding = r6;
|
||||
|
||||
visuals.widgets.hovered.bg_fill = theme.bg05;
|
||||
visuals.widgets.hovered.weak_bg_fill = theme.bg05;
|
||||
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0_f32, theme.brand_green);
|
||||
visuals.widgets.hovered.fg_stroke = Stroke::new(1.0_f32, theme.text01);
|
||||
visuals.widgets.hovered.rounding = r6;
|
||||
|
||||
visuals.widgets.active.bg_fill = theme.brand_green;
|
||||
visuals.widgets.active.weak_bg_fill = theme.brand_green;
|
||||
visuals.widgets.active.bg_stroke = Stroke::new(1.0_f32, theme.brand_green_lighter);
|
||||
visuals.widgets.active.fg_stroke = Stroke::new(1.0_f32, Color32::WHITE);
|
||||
visuals.widgets.active.rounding = r6;
|
||||
|
||||
visuals.widgets.open.bg_fill = theme.bg04;
|
||||
visuals.widgets.open.weak_bg_fill = theme.bg04;
|
||||
visuals.widgets.open.bg_stroke = Stroke::new(1.0_f32, theme.brand_green);
|
||||
visuals.widgets.open.fg_stroke = Stroke::new(1.0_f32, theme.text01);
|
||||
visuals.widgets.open.rounding = r6;
|
||||
|
||||
style.spacing.item_spacing = Vec2::new(8.0_f32, 6.0_f32);
|
||||
style.spacing.button_padding = Vec2::new(8.0_f32, 5.0_f32);
|
||||
style.visuals = visuals;
|
||||
ctx.set_style(style);
|
||||
|
||||
theme
|
||||
}
|
||||
|
||||
/// Renders a rounded card container with `fill(theme.bg02)`, `rounding(8.0)`,
|
||||
/// and `inner_margin(Margin::symmetric(16.0, 12.0))`.
|
||||
pub fn ins_card<R>(
|
||||
ui: &mut Ui,
|
||||
theme: &InsTheme,
|
||||
add_contents: impl FnOnce(&mut Ui) -> R,
|
||||
) -> R {
|
||||
Frame::none()
|
||||
.fill(theme.bg02)
|
||||
.rounding(Rounding::same(8.0_f32))
|
||||
.inner_margin(Margin::symmetric(16.0_f32, 12.0_f32))
|
||||
.show(ui, add_contents)
|
||||
.inner
|
||||
}
|
||||
|
||||
/// Renders a 32px-tall header row with `title` on the left (14.0 strong `theme.text01`)
|
||||
/// and an optional `↺` reset button on the right (returns `true` if clicked).
|
||||
pub fn ins_section_header(
|
||||
ui: &mut Ui,
|
||||
title: &str,
|
||||
theme: &InsTheme,
|
||||
reset_tooltip: Option<&str>,
|
||||
) -> bool {
|
||||
let mut reset_clicked = false;
|
||||
ui.allocate_ui_with_layout(
|
||||
Vec2::new(ui.available_width(), 32.0_f32),
|
||||
Layout::left_to_right(Align::Center),
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new(title)
|
||||
.size(14.0_f32)
|
||||
.strong()
|
||||
.color(theme.text01),
|
||||
);
|
||||
if let Some(tooltip) = reset_tooltip {
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
let btn = ui.add(
|
||||
Button::new(RichText::new("↺").size(14.0_f32).color(theme.text03))
|
||||
.frame(false),
|
||||
);
|
||||
if !tooltip.is_empty() {
|
||||
btn.clone().on_hover_text(tooltip);
|
||||
}
|
||||
if btn.clicked() {
|
||||
reset_clicked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
reset_clicked
|
||||
}
|
||||
|
||||
/// Draws a 34.0 x 18.0 pill toggle switch (`theme.brand_green` when `*checked`,
|
||||
/// `theme.bg05` when false, white circle thumb) and returns `true` if toggled.
|
||||
pub fn ins_switch(ui: &mut Ui, checked: &mut bool, enabled: bool, theme: &InsTheme) -> bool {
|
||||
let desired_size = Vec2::new(34.0_f32, 18.0_f32);
|
||||
let (rect, mut response) = ui.allocate_exact_size(
|
||||
desired_size,
|
||||
if enabled {
|
||||
Sense::click()
|
||||
} else {
|
||||
Sense::hover()
|
||||
},
|
||||
);
|
||||
if enabled && response.clicked() {
|
||||
*checked = !*checked;
|
||||
response.mark_changed();
|
||||
}
|
||||
response.widget_info(|| {
|
||||
WidgetInfo::selected(
|
||||
WidgetType::Checkbox,
|
||||
ui.is_enabled() && enabled,
|
||||
*checked,
|
||||
"",
|
||||
)
|
||||
});
|
||||
|
||||
if ui.is_rect_visible(rect) {
|
||||
let how_on = ui.ctx().animate_bool(response.id, *checked);
|
||||
let track_rounding = Rounding::same(rect.height() / 2.0_f32);
|
||||
|
||||
let track_color = if enabled {
|
||||
if *checked {
|
||||
theme.brand_green
|
||||
} else {
|
||||
theme.bg05
|
||||
}
|
||||
} else if *checked {
|
||||
Color32::from_rgba_unmultiplied(
|
||||
theme.brand_green.r(),
|
||||
theme.brand_green.g(),
|
||||
theme.brand_green.b(),
|
||||
100,
|
||||
)
|
||||
} else {
|
||||
theme.border01
|
||||
};
|
||||
|
||||
ui.painter().rect_filled(rect, track_rounding, track_color);
|
||||
|
||||
let thumb_radius = (rect.height() - 4.0_f32) / 2.0_f32;
|
||||
let min_x = rect.min.x + 2.0_f32 + thumb_radius;
|
||||
let max_x = rect.max.x - 2.0_f32 - thumb_radius;
|
||||
let thumb_x = egui::lerp(min_x..=max_x, how_on);
|
||||
let thumb_center = Pos2::new(thumb_x, rect.center().y);
|
||||
let thumb_color = if enabled {
|
||||
Color32::WHITE
|
||||
} else {
|
||||
theme.text04
|
||||
};
|
||||
|
||||
ui.painter()
|
||||
.circle_filled(thumb_center, thumb_radius, thumb_color);
|
||||
}
|
||||
|
||||
response.changed()
|
||||
}
|
||||
|
||||
/// Full-width row (28px height) with `label` (13.0 `theme.text01`) + optional `?` hover tooltip
|
||||
/// on the left, and `ins_switch` on the right. Returns `true` if toggled.
|
||||
pub fn ins_switch_row(
|
||||
ui: &mut Ui,
|
||||
label: &str,
|
||||
tooltip: Option<&str>,
|
||||
checked: &mut bool,
|
||||
enabled: bool,
|
||||
theme: &InsTheme,
|
||||
) -> bool {
|
||||
let row_height = 28.0_f32;
|
||||
let (row_rect, row_response) = ui.allocate_exact_size(
|
||||
Vec2::new(ui.available_width(), row_height),
|
||||
if enabled {
|
||||
Sense::click()
|
||||
} else {
|
||||
Sense::hover()
|
||||
},
|
||||
);
|
||||
|
||||
let mut toggled = false;
|
||||
|
||||
let switch_width = 34.0_f32;
|
||||
let switch_height = 18.0_f32;
|
||||
let switch_rect = Rect::from_min_size(
|
||||
Pos2::new(
|
||||
row_rect.max.x - switch_width,
|
||||
row_rect.center().y - switch_height / 2.0_f32,
|
||||
),
|
||||
Vec2::new(switch_width, switch_height),
|
||||
);
|
||||
|
||||
let switch_id = row_response.id.with("switch");
|
||||
let switch_response = ui.interact(
|
||||
switch_rect,
|
||||
switch_id,
|
||||
if enabled {
|
||||
Sense::click()
|
||||
} else {
|
||||
Sense::hover()
|
||||
},
|
||||
);
|
||||
|
||||
if enabled {
|
||||
if switch_response.clicked() {
|
||||
*checked = !*checked;
|
||||
toggled = true;
|
||||
} else if row_response.clicked() {
|
||||
*checked = !*checked;
|
||||
toggled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ui.is_rect_visible(row_rect) {
|
||||
let text_color = if enabled {
|
||||
theme.text01
|
||||
} else {
|
||||
theme.text04
|
||||
};
|
||||
let text_pos = Pos2::new(row_rect.min.x, row_rect.center().y);
|
||||
|
||||
let label_galley = ui.painter().layout_no_wrap(
|
||||
label.to_string(),
|
||||
FontId::proportional(13.0_f32),
|
||||
text_color,
|
||||
);
|
||||
let label_size = label_galley.size();
|
||||
ui.painter().galley(
|
||||
Pos2::new(text_pos.x, text_pos.y - label_size.y / 2.0_f32),
|
||||
label_galley,
|
||||
text_color,
|
||||
);
|
||||
|
||||
if let Some(tt) = tooltip {
|
||||
let tip_x = text_pos.x + label_size.x + 6.0_f32;
|
||||
let tip_rect = Rect::from_min_size(
|
||||
Pos2::new(tip_x, text_pos.y - 8.0_f32),
|
||||
Vec2::new(16.0_f32, 16.0_f32),
|
||||
);
|
||||
let tip_resp = ui.interact(tip_rect, row_response.id.with("tt"), Sense::hover());
|
||||
let tip_color = if tip_resp.hovered() {
|
||||
theme.text01
|
||||
} else {
|
||||
theme.text03
|
||||
};
|
||||
ui.painter().text(
|
||||
tip_rect.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
"?",
|
||||
FontId::proportional(12.0_f32),
|
||||
tip_color,
|
||||
);
|
||||
if !tt.is_empty() {
|
||||
tip_resp.on_hover_text(tt);
|
||||
}
|
||||
}
|
||||
|
||||
// Render switch
|
||||
let how_on = ui.ctx().animate_bool(switch_id, *checked);
|
||||
let track_rounding = Rounding::same(switch_rect.height() / 2.0_f32);
|
||||
let track_color = if enabled {
|
||||
if *checked {
|
||||
theme.brand_green
|
||||
} else {
|
||||
theme.bg05
|
||||
}
|
||||
} else if *checked {
|
||||
Color32::from_rgba_unmultiplied(
|
||||
theme.brand_green.r(),
|
||||
theme.brand_green.g(),
|
||||
theme.brand_green.b(),
|
||||
100,
|
||||
)
|
||||
} else {
|
||||
theme.border01
|
||||
};
|
||||
|
||||
ui.painter()
|
||||
.rect_filled(switch_rect, track_rounding, track_color);
|
||||
|
||||
let thumb_radius = (switch_rect.height() - 4.0_f32) / 2.0_f32;
|
||||
let min_x = switch_rect.min.x + 2.0_f32 + thumb_radius;
|
||||
let max_x = switch_rect.max.x - 2.0_f32 - thumb_radius;
|
||||
let thumb_x = egui::lerp(min_x..=max_x, how_on);
|
||||
let thumb_center = Pos2::new(thumb_x, switch_rect.center().y);
|
||||
let thumb_color = if enabled {
|
||||
Color32::WHITE
|
||||
} else {
|
||||
theme.text04
|
||||
};
|
||||
|
||||
ui.painter()
|
||||
.circle_filled(thumb_center, thumb_radius, thumb_color);
|
||||
}
|
||||
|
||||
toggled
|
||||
}
|
||||
|
||||
/// Full-width row (28px height) with optional `icon_prefix` + `label` on the left
|
||||
/// and a custom 16x16 rounded checkbox (`theme.brand_green` fill + checkmark when `*checked`)
|
||||
/// on the right. Returns `true` if toggled.
|
||||
pub fn ins_checkbox_row(
|
||||
ui: &mut Ui,
|
||||
label: &str,
|
||||
icon_prefix: Option<&str>,
|
||||
tooltip: Option<&str>,
|
||||
checked: &mut bool,
|
||||
enabled: bool,
|
||||
theme: &InsTheme,
|
||||
) -> bool {
|
||||
let row_height = 28.0_f32;
|
||||
let (row_rect, row_response) = ui.allocate_exact_size(
|
||||
Vec2::new(ui.available_width(), row_height),
|
||||
if enabled {
|
||||
Sense::click()
|
||||
} else {
|
||||
Sense::hover()
|
||||
},
|
||||
);
|
||||
|
||||
let mut toggled = false;
|
||||
|
||||
let box_size = 16.0_f32;
|
||||
let box_rect = Rect::from_min_size(
|
||||
Pos2::new(
|
||||
row_rect.max.x - box_size,
|
||||
row_rect.center().y - box_size / 2.0_f32,
|
||||
),
|
||||
Vec2::new(box_size, box_size),
|
||||
);
|
||||
|
||||
let box_id = row_response.id.with("cb");
|
||||
let box_response = ui.interact(
|
||||
box_rect,
|
||||
box_id,
|
||||
if enabled {
|
||||
Sense::click()
|
||||
} else {
|
||||
Sense::hover()
|
||||
},
|
||||
);
|
||||
|
||||
if enabled && (box_response.clicked() || row_response.clicked()) {
|
||||
*checked = !*checked;
|
||||
toggled = true;
|
||||
}
|
||||
|
||||
if ui.is_rect_visible(row_rect) {
|
||||
let text_color = if enabled {
|
||||
theme.text01
|
||||
} else {
|
||||
theme.text04
|
||||
};
|
||||
let mut cur_x = row_rect.min.x;
|
||||
|
||||
if let Some(prefix) = icon_prefix {
|
||||
let prefix_galley = ui.painter().layout_no_wrap(
|
||||
prefix.to_string(),
|
||||
FontId::proportional(14.0_f32),
|
||||
theme.text03,
|
||||
);
|
||||
let p_size = prefix_galley.size();
|
||||
ui.painter().galley(
|
||||
Pos2::new(cur_x, row_rect.center().y - p_size.y / 2.0_f32),
|
||||
prefix_galley,
|
||||
theme.text03,
|
||||
);
|
||||
cur_x += p_size.x + 6.0_f32;
|
||||
}
|
||||
|
||||
let label_galley = ui.painter().layout_no_wrap(
|
||||
label.to_string(),
|
||||
FontId::proportional(13.0_f32),
|
||||
text_color,
|
||||
);
|
||||
let label_size = label_galley.size();
|
||||
ui.painter().galley(
|
||||
Pos2::new(cur_x, row_rect.center().y - label_size.y / 2.0_f32),
|
||||
label_galley,
|
||||
text_color,
|
||||
);
|
||||
cur_x += label_size.x + 6.0_f32;
|
||||
|
||||
if let Some(tt) = tooltip {
|
||||
let tip_rect = Rect::from_min_size(
|
||||
Pos2::new(cur_x, row_rect.center().y - 8.0_f32),
|
||||
Vec2::new(16.0_f32, 16.0_f32),
|
||||
);
|
||||
let tip_resp = ui.interact(tip_rect, row_response.id.with("cb_tt"), Sense::hover());
|
||||
let tip_color = if tip_resp.hovered() {
|
||||
theme.text01
|
||||
} else {
|
||||
theme.text03
|
||||
};
|
||||
ui.painter().text(
|
||||
tip_rect.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
"?",
|
||||
FontId::proportional(12.0_f32),
|
||||
tip_color,
|
||||
);
|
||||
if !tt.is_empty() {
|
||||
tip_resp.on_hover_text(tt);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw checkbox indicator
|
||||
let r3 = Rounding::same(3.0_f32);
|
||||
if *checked {
|
||||
let fill = if enabled {
|
||||
theme.brand_green
|
||||
} else {
|
||||
Color32::from_rgba_unmultiplied(
|
||||
theme.brand_green.r(),
|
||||
theme.brand_green.g(),
|
||||
theme.brand_green.b(),
|
||||
120,
|
||||
)
|
||||
};
|
||||
ui.painter().rect_filled(box_rect, r3, fill);
|
||||
|
||||
// Dark checkmark
|
||||
let check_color = if theme.is_light_mode {
|
||||
Color32::WHITE
|
||||
} else {
|
||||
Color32::from_rgb(20, 20, 20)
|
||||
};
|
||||
let stroke = Stroke::new(1.8_f32, check_color);
|
||||
let p1 = Pos2::new(box_rect.min.x + 3.5_f32, box_rect.min.y + 8.0_f32);
|
||||
let p2 = Pos2::new(box_rect.min.x + 6.5_f32, box_rect.min.y + 11.5_f32);
|
||||
let p3 = Pos2::new(box_rect.min.x + 12.5_f32, box_rect.min.y + 4.5_f32);
|
||||
ui.painter().line_segment([p1, p2], stroke);
|
||||
ui.painter().line_segment([p2, p3], stroke);
|
||||
} else {
|
||||
let bg = if enabled && box_response.hovered() {
|
||||
theme.bg04
|
||||
} else {
|
||||
theme.bg03
|
||||
};
|
||||
let stroke_color = if enabled && box_response.hovered() {
|
||||
theme.brand_green
|
||||
} else {
|
||||
theme.border02
|
||||
};
|
||||
ui.painter().rect_filled(box_rect, r3, bg);
|
||||
ui.painter()
|
||||
.rect_stroke(box_rect, r3, Stroke::new(1.0_f32, stroke_color));
|
||||
}
|
||||
}
|
||||
|
||||
toggled
|
||||
}
|
||||
|
||||
/// Matches `DefSlider.qml`: top row has `label` on the left (if `Some`) and current value + `unit`
|
||||
/// on the right; bottom row has a custom full-width slider track (`theme.brand_green` left of thumb,
|
||||
/// `theme.bg05` right of thumb). Returns `true` if changed.
|
||||
pub fn ins_slider_i32(
|
||||
ui: &mut Ui,
|
||||
label: Option<&str>,
|
||||
value: &mut i32,
|
||||
min: i32,
|
||||
max: i32,
|
||||
unit: &str,
|
||||
enabled: bool,
|
||||
theme: &InsTheme,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
let initial_val = *value;
|
||||
|
||||
ui.vertical(|ui| {
|
||||
// Top row
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(lbl) = label {
|
||||
ui.label(
|
||||
RichText::new(lbl).size(13.0_f32).color(if enabled {
|
||||
theme.text02
|
||||
} else {
|
||||
theme.text04
|
||||
}),
|
||||
);
|
||||
}
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
let val_str = format!("{}{}", *value, unit);
|
||||
ui.label(
|
||||
RichText::new(val_str).size(12.0_f32).color(if enabled {
|
||||
theme.text01
|
||||
} else {
|
||||
theme.text04
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Bottom track
|
||||
let track_height = 16.0_f32;
|
||||
let (track_rect, response) = ui.allocate_exact_size(
|
||||
Vec2::new(ui.available_width(), track_height),
|
||||
if enabled {
|
||||
Sense::click_and_drag()
|
||||
} else {
|
||||
Sense::hover()
|
||||
},
|
||||
);
|
||||
|
||||
let thumb_radius = 6.0_f32;
|
||||
let min_x = track_rect.min.x + thumb_radius;
|
||||
let max_x = track_rect.max.x - thumb_radius;
|
||||
let range = (max - min).max(1) as f32;
|
||||
|
||||
if enabled && (response.clicked() || response.dragged()) {
|
||||
if let Some(pos) = response.interact_pointer_pos() {
|
||||
let norm = ((pos.x - min_x) / (max_x - min_x).max(1.0_f32)).clamp(0.0_f32, 1.0_f32);
|
||||
let new_val = min + (norm * range).round() as i32;
|
||||
let clamped_val = new_val.clamp(min, max);
|
||||
if clamped_val != *value {
|
||||
*value = clamped_val;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ui.is_rect_visible(track_rect) {
|
||||
let norm = ((*value - min) as f32 / range).clamp(0.0_f32, 1.0_f32);
|
||||
let thumb_x = min_x + norm * (max_x - min_x);
|
||||
let bar_y = track_rect.center().y;
|
||||
let bar_h = 3.0_f32;
|
||||
let bar_rounding = Rounding::same(1.5_f32);
|
||||
|
||||
// Left track (brand_green)
|
||||
let left_rect = Rect::from_min_max(
|
||||
Pos2::new(track_rect.min.x, bar_y - bar_h / 2.0_f32),
|
||||
Pos2::new(thumb_x, bar_y + bar_h / 2.0_f32),
|
||||
);
|
||||
ui.painter().rect_filled(
|
||||
left_rect,
|
||||
bar_rounding,
|
||||
if enabled {
|
||||
theme.brand_green
|
||||
} else {
|
||||
theme.border01
|
||||
},
|
||||
);
|
||||
|
||||
// Right track (bg05)
|
||||
let right_rect = Rect::from_min_max(
|
||||
Pos2::new(thumb_x, bar_y - bar_h / 2.0_f32),
|
||||
Pos2::new(track_rect.max.x, bar_y + bar_h / 2.0_f32),
|
||||
);
|
||||
ui.painter().rect_filled(right_rect, bar_rounding, theme.bg05);
|
||||
|
||||
// Thumb
|
||||
let thumb_center = Pos2::new(thumb_x, bar_y);
|
||||
let thumb_fill = if enabled {
|
||||
Color32::WHITE
|
||||
} else {
|
||||
theme.text04
|
||||
};
|
||||
ui.painter()
|
||||
.circle_filled(thumb_center, thumb_radius, thumb_fill);
|
||||
ui.painter()
|
||||
.circle_stroke(thumb_center, thumb_radius, Stroke::new(1.0_f32, theme.border03));
|
||||
}
|
||||
});
|
||||
|
||||
changed || *value != initial_val
|
||||
}
|
||||
|
||||
/// Matches `DefSlider.qml` for `f32`: top row has `label` and formatted value + `unit`;
|
||||
/// bottom row has custom track. Returns `true` if changed.
|
||||
pub fn ins_slider_f32(
|
||||
ui: &mut Ui,
|
||||
label: Option<&str>,
|
||||
value: &mut f32,
|
||||
min: f32,
|
||||
max: f32,
|
||||
step: f32,
|
||||
decimals: usize,
|
||||
unit: &str,
|
||||
enabled: bool,
|
||||
theme: &InsTheme,
|
||||
) -> bool {
|
||||
let mut changed = false;
|
||||
let initial_val = *value;
|
||||
|
||||
ui.vertical(|ui| {
|
||||
// Top row
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(lbl) = label {
|
||||
ui.label(
|
||||
RichText::new(lbl).size(13.0_f32).color(if enabled {
|
||||
theme.text02
|
||||
} else {
|
||||
theme.text04
|
||||
}),
|
||||
);
|
||||
}
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
let val_str = format!("{:.decimals$}{}", *value, unit);
|
||||
ui.label(
|
||||
RichText::new(val_str).size(12.0_f32).color(if enabled {
|
||||
theme.text01
|
||||
} else {
|
||||
theme.text04
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Bottom track
|
||||
let track_height = 16.0_f32;
|
||||
let (track_rect, response) = ui.allocate_exact_size(
|
||||
Vec2::new(ui.available_width(), track_height),
|
||||
if enabled {
|
||||
Sense::click_and_drag()
|
||||
} else {
|
||||
Sense::hover()
|
||||
},
|
||||
);
|
||||
|
||||
let thumb_radius = 6.0_f32;
|
||||
let min_x = track_rect.min.x + thumb_radius;
|
||||
let max_x = track_rect.max.x - thumb_radius;
|
||||
let range = (max - min).max(0.0001_f32);
|
||||
|
||||
if enabled && (response.clicked() || response.dragged()) {
|
||||
if let Some(pos) = response.interact_pointer_pos() {
|
||||
let norm = ((pos.x - min_x) / (max_x - min_x).max(1.0_f32)).clamp(0.0_f32, 1.0_f32);
|
||||
let mut new_val = min + norm * range;
|
||||
if step > 0.0_f32 {
|
||||
new_val = (new_val / step).round() * step;
|
||||
}
|
||||
let clamped_val = new_val.clamp(min, max);
|
||||
if (clamped_val - *value).abs() > 1e-6_f32 {
|
||||
*value = clamped_val;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ui.is_rect_visible(track_rect) {
|
||||
let norm = ((*value - min) / range).clamp(0.0_f32, 1.0_f32);
|
||||
let thumb_x = min_x + norm * (max_x - min_x);
|
||||
let bar_y = track_rect.center().y;
|
||||
let bar_h = 3.0_f32;
|
||||
let bar_rounding = Rounding::same(1.5_f32);
|
||||
|
||||
// Left track (brand_green)
|
||||
let left_rect = Rect::from_min_max(
|
||||
Pos2::new(track_rect.min.x, bar_y - bar_h / 2.0_f32),
|
||||
Pos2::new(thumb_x, bar_y + bar_h / 2.0_f32),
|
||||
);
|
||||
ui.painter().rect_filled(
|
||||
left_rect,
|
||||
bar_rounding,
|
||||
if enabled {
|
||||
theme.brand_green
|
||||
} else {
|
||||
theme.border01
|
||||
},
|
||||
);
|
||||
|
||||
// Right track (bg05)
|
||||
let right_rect = Rect::from_min_max(
|
||||
Pos2::new(thumb_x, bar_y - bar_h / 2.0_f32),
|
||||
Pos2::new(track_rect.max.x, bar_y + bar_h / 2.0_f32),
|
||||
);
|
||||
ui.painter().rect_filled(right_rect, bar_rounding, theme.bg05);
|
||||
|
||||
// Thumb
|
||||
let thumb_center = Pos2::new(thumb_x, bar_y);
|
||||
let thumb_fill = if enabled {
|
||||
Color32::WHITE
|
||||
} else {
|
||||
theme.text04
|
||||
};
|
||||
ui.painter()
|
||||
.circle_filled(thumb_center, thumb_radius, thumb_fill);
|
||||
ui.painter()
|
||||
.circle_stroke(thumb_center, thumb_radius, Stroke::new(1.0_f32, theme.border03));
|
||||
}
|
||||
});
|
||||
|
||||
changed || (*value - initial_val).abs() > 1e-6_f32
|
||||
}
|
||||
|
||||
/// Renders a tab button (`width x 32.0`, `rounding(6.0)`, `fill(if active { theme.bg03 } else { Color32::TRANSPARENT })`,
|
||||
/// text color `if active { theme.brand_green } else { theme.text02 }`). Returns `true` if clicked.
|
||||
pub fn ins_tab_button(
|
||||
ui: &mut Ui,
|
||||
label: &str,
|
||||
active: bool,
|
||||
width: f32,
|
||||
theme: &InsTheme,
|
||||
) -> bool {
|
||||
let size = Vec2::new(width, 32.0_f32);
|
||||
let (rect, response) = ui.allocate_exact_size(size, Sense::click());
|
||||
|
||||
if ui.is_rect_visible(rect) {
|
||||
let fill = if active {
|
||||
theme.bg03
|
||||
} else if response.hovered() {
|
||||
theme.bg04
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
|
||||
ui.painter().rect_filled(rect, Rounding::same(6.0_f32), fill);
|
||||
|
||||
let text_color = if active {
|
||||
theme.brand_green
|
||||
} else if response.hovered() {
|
||||
theme.text01
|
||||
} else {
|
||||
theme.text02
|
||||
};
|
||||
|
||||
ui.painter().text(
|
||||
rect.center(),
|
||||
Align2::CENTER_CENTER,
|
||||
label,
|
||||
FontId::proportional(13.0_f32),
|
||||
text_color,
|
||||
);
|
||||
}
|
||||
|
||||
response.clicked()
|
||||
}
|
||||
|
||||
/// Renders a 74.0 x 52.0 mode button for `BottomControl.qml` with `icon` (18px)
|
||||
/// centered above `label` (11.5px), colored `theme.brand_green` when `active` or
|
||||
/// `theme.text01` when inactive.
|
||||
pub fn ins_bottom_mode_button(
|
||||
ui: &mut Ui,
|
||||
icon: &str,
|
||||
label: &str,
|
||||
active: bool,
|
||||
enabled: bool,
|
||||
theme: &InsTheme,
|
||||
) -> Response {
|
||||
let size = Vec2::new(74.0_f32, 52.0_f32);
|
||||
let (rect, response) = ui.allocate_exact_size(
|
||||
size,
|
||||
if enabled {
|
||||
Sense::click()
|
||||
} else {
|
||||
Sense::hover()
|
||||
},
|
||||
);
|
||||
|
||||
if ui.is_rect_visible(rect) {
|
||||
let r6 = Rounding::same(6.0_f32);
|
||||
let bg_fill = if active {
|
||||
theme.brand_light
|
||||
} else if enabled && response.hovered() {
|
||||
theme.bg03
|
||||
} else {
|
||||
Color32::TRANSPARENT
|
||||
};
|
||||
|
||||
ui.painter().rect_filled(rect, r6, bg_fill);
|
||||
|
||||
if active {
|
||||
ui.painter()
|
||||
.rect_stroke(rect, r6, Stroke::new(1.0_f32, theme.brand_green));
|
||||
}
|
||||
|
||||
let main_color = if !enabled {
|
||||
theme.text04
|
||||
} else if active {
|
||||
theme.brand_green
|
||||
} else {
|
||||
theme.text01
|
||||
};
|
||||
|
||||
let label_color = if !enabled {
|
||||
theme.text04
|
||||
} else if active {
|
||||
theme.brand_green
|
||||
} else {
|
||||
theme.text02
|
||||
};
|
||||
|
||||
// Icon at top
|
||||
let icon_pos = Pos2::new(rect.center().x, rect.min.y + 18.0_f32);
|
||||
ui.painter().text(
|
||||
icon_pos,
|
||||
Align2::CENTER_CENTER,
|
||||
icon,
|
||||
FontId::proportional(18.0_f32),
|
||||
main_color,
|
||||
);
|
||||
|
||||
// Label below
|
||||
let label_pos = Pos2::new(rect.center().x, rect.min.y + 38.0_f32);
|
||||
ui.painter().text(
|
||||
label_pos,
|
||||
Align2::CENTER_CENTER,
|
||||
label,
|
||||
FontId::proportional(11.5_f32),
|
||||
label_color,
|
||||
);
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use insta360_link_protocol::{
|
||||
ct_selectors, pu_selectors, xu1_selectors, xu2_selectors, CameraModel, DiscoveredCamera,
|
||||
MockCameraTransport, NusbTransport, TransportError, UvcControlPacketHeader,
|
||||
UvcControlTransport, UvcRequest, UvcUnitIds,
|
||||
};
|
||||
|
||||
/// Unified GUI transport wrapper allowing runtime switching between physical USB cameras
|
||||
/// (`NusbTransport`) and stateful in-memory camera simulators (`MockCameraTransport`).
|
||||
pub enum GuiTransport {
|
||||
Usb(NusbTransport),
|
||||
Mock(MockCameraTransport),
|
||||
}
|
||||
|
||||
impl GuiTransport {
|
||||
pub fn is_mock(&self) -> bool {
|
||||
matches!(self, Self::Mock(_))
|
||||
}
|
||||
|
||||
pub fn command_log(&self) -> Option<Vec<(UvcRequest, u8, u8, Vec<u8>)>> {
|
||||
match self {
|
||||
Self::Usb(_) => None,
|
||||
Self::Mock(mock) => Some(mock.command_log()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UvcControlTransport for GuiTransport {
|
||||
fn camera_info(&self) -> &DiscoveredCamera {
|
||||
match self {
|
||||
Self::Usb(usb) => usb.camera_info(),
|
||||
Self::Mock(mock) => mock.camera_info(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_cur(&self, unit_id: u8, selector: u8, data: &[u8]) -> Result<(), TransportError> {
|
||||
match self {
|
||||
Self::Usb(usb) => usb.set_cur(unit_id, selector, data),
|
||||
Self::Mock(mock) => mock.set_cur(unit_id, selector, data),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_req(
|
||||
&self,
|
||||
req: UvcRequest,
|
||||
unit_id: u8,
|
||||
selector: u8,
|
||||
len: usize,
|
||||
) -> Result<Vec<u8>, TransportError> {
|
||||
match self {
|
||||
Self::Usb(usb) => usb.get_req(req, unit_id, selector, len),
|
||||
Self::Mock(mock) => mock.get_req(req, unit_id, selector, len),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Target device selection in the GUI header dropdown.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DeviceSelection {
|
||||
Usb(DiscoveredCamera),
|
||||
Simulate(CameraModel),
|
||||
}
|
||||
|
||||
impl DeviceSelection {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Self::Usb(cam) => format!(
|
||||
"USB: {} (Bus {} Addr {} • 0x{:04x})",
|
||||
cam.name, cam.bus_number, cam.device_address, cam.product_id
|
||||
),
|
||||
Self::Simulate(model) => format!(
|
||||
"Simulate: {} (0x{:04x})",
|
||||
model.display_name(),
|
||||
model.pid()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a human-readable UVC unit name for the protocol inspector.
|
||||
pub fn describe_unit(unit_id: u8, ids: &UvcUnitIds) -> &'static str {
|
||||
if unit_id == ids.input_terminal_id {
|
||||
"IT (Camera Terminal)"
|
||||
} else if unit_id == ids.processing_unit_id {
|
||||
"PU (Processing Unit)"
|
||||
} else if unit_id == ids.extension_unit_id {
|
||||
"XU1 (Insta360 Ext 1)"
|
||||
} else if unit_id == ids.extension_unit2_id {
|
||||
"XU2 (Insta360 Ext 2)"
|
||||
} else {
|
||||
"Unknown Unit"
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a human-readable UVC control selector symbol for the protocol inspector.
|
||||
pub fn describe_selector(unit_id: u8, selector: u8, ids: &UvcUnitIds) -> &'static str {
|
||||
if unit_id == ids.input_terminal_id {
|
||||
match selector {
|
||||
ct_selectors::AE_MODE => "CT_AE_MODE_CONTROL",
|
||||
ct_selectors::EXPOSURE_TIME_ABSOLUTE => "CT_EXPOSURE_TIME_ABSOLUTE",
|
||||
ct_selectors::FOCUS_ABSOLUTE => "CT_FOCUS_ABSOLUTE",
|
||||
ct_selectors::FOCUS_AUTO => "CT_FOCUS_AUTO",
|
||||
ct_selectors::ZOOM_ABSOLUTE => "CT_ZOOM_ABSOLUTE",
|
||||
ct_selectors::PANTILT_ABSOLUTE => "CT_PANTILT_ABSOLUTE",
|
||||
ct_selectors::PANTILT_RELATIVE => "CT_PANTILT_RELATIVE",
|
||||
ct_selectors::ROLL_ABSOLUTE => "CT_ROLL_ABSOLUTE",
|
||||
_ => "CT_UNKNOWN",
|
||||
}
|
||||
} else if unit_id == ids.processing_unit_id {
|
||||
match selector {
|
||||
pu_selectors::BRIGHTNESS => "PU_BRIGHTNESS_CONTROL",
|
||||
pu_selectors::CONTRAST => "PU_CONTRAST_CONTROL",
|
||||
pu_selectors::POWER_LINE_FREQUENCY => "PU_POWER_LINE_FREQUENCY",
|
||||
pu_selectors::SATURATION => "PU_SATURATION_CONTROL",
|
||||
pu_selectors::SHARPNESS => "PU_SHARPNESS_CONTROL",
|
||||
pu_selectors::WHITE_BALANCE_TEMPERATURE => "PU_WB_TEMPERATURE",
|
||||
pu_selectors::WHITE_BALANCE_TEMPERATURE_AUTO => "PU_WB_TEMPERATURE_AUTO",
|
||||
_ => "PU_UNKNOWN",
|
||||
}
|
||||
} else if unit_id == ids.extension_unit_id {
|
||||
match selector {
|
||||
xu1_selectors::SYSTEM_AND_PRESET_CMD => "XU1_SYSTEM_AND_PRESET_CMD",
|
||||
xu1_selectors::VIDEO_MODE => "XU1_VIDEO_MODE",
|
||||
xu1_selectors::TRACKING_AND_GESTURE => "XU1_TRACKING_AND_GESTURE",
|
||||
xu1_selectors::AUDIO_CONTROL => "XU1_AUDIO_CONTROL",
|
||||
xu1_selectors::TRACK_OBJECT_SELECT => "XU1_TRACK_OBJECT_SELECT",
|
||||
xu1_selectors::TRACK_OBJECT_LIST_V1 => "XU1_TRACK_OBJECT_LIST_V1",
|
||||
xu1_selectors::EXPOSURE_CURVE_AND_ISO => "XU1_EXPOSURE_CURVE_AND_ISO",
|
||||
xu1_selectors::EXPOSURE_COMPENSATION => "XU1_EXPOSURE_COMPENSATION",
|
||||
xu1_selectors::WHITE_BALANCE_EXT => "XU1_WHITE_BALANCE_EXT",
|
||||
xu1_selectors::EXTEND_FUNC_BITMASK => "XU1_EXTEND_FUNC_BITMASK",
|
||||
xu1_selectors::PANTILT_RELATIVE_EXT => "XU1_PANTILT_RELATIVE_EXT",
|
||||
xu1_selectors::DEVICE_STATUS => "XU1_DEVICE_STATUS",
|
||||
xu1_selectors::PTZ_VERSION_AND_ABS => "XU1_PTZ_VERSION_AND_ABS",
|
||||
xu1_selectors::PRESET_POSITION_FLAGS => "XU1_PRESET_POSITION_FLAGS",
|
||||
xu1_selectors::USB_MODE_SWITCH => "XU1_USB_MODE_SWITCH",
|
||||
_ => "XU1_UNKNOWN",
|
||||
}
|
||||
} else if unit_id == ids.extension_unit2_id {
|
||||
match selector {
|
||||
xu2_selectors::GESTURE_BINDING_MAP => "XU2_GESTURE_BINDING_MAP",
|
||||
xu2_selectors::SYSTEM_TIME_AND_IQ => "XU2_SYSTEM_TIME_AND_IQ",
|
||||
xu2_selectors::DEVICE_INFO => "XU2_DEVICE_INFO",
|
||||
xu2_selectors::IMAGE_TEMPLATE_INFO => "XU2_IMAGE_TEMPLATE_INFO",
|
||||
xu2_selectors::IMAGE_TEMPLATE_NAME => "XU2_IMAGE_TEMPLATE_NAME",
|
||||
xu2_selectors::FILTER_MODE => "XU2_FILTER_MODE",
|
||||
xu2_selectors::PRIVACY_AND_FILL_LIGHT => "XU2_PRIVACY_AND_FILL_LIGHT",
|
||||
xu2_selectors::HOST_PTZ => "XU2_HOST_PTZ",
|
||||
xu2_selectors::TRACK_OBJECT_LIST_V2 => "XU2_TRACK_OBJECT_LIST_V2",
|
||||
xu2_selectors::TRACK_FORBIDDEN_AREA => "XU2_TRACK_FORBIDDEN_AREA",
|
||||
xu2_selectors::DEFAULT_TRACK_MODE => "XU2_DEFAULT_TRACK_MODE",
|
||||
xu2_selectors::HORIZONTAL_CORRECTION => "XU2_HORIZONTAL_CORRECTION",
|
||||
xu2_selectors::REAL_ZOOM_AND_UDISK => "XU2_REAL_ZOOM_AND_UDISK",
|
||||
xu2_selectors::REPORT_DATA => "XU2_REPORT_DATA",
|
||||
_ => "XU2_UNKNOWN",
|
||||
}
|
||||
} else {
|
||||
"UNKNOWN_SELECTOR"
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a byte slice as space-separated lowercase hex.
|
||||
pub fn format_hex(bytes: &[u8]) -> String {
|
||||
bytes
|
||||
.iter()
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Computes the UVC setup packet header for display in the Protocol Inspector.
|
||||
pub fn packet_header(
|
||||
req: UvcRequest,
|
||||
unit_id: u8,
|
||||
selector: u8,
|
||||
ids: &UvcUnitIds,
|
||||
) -> UvcControlPacketHeader {
|
||||
UvcControlPacketHeader::new(req, selector, unit_id, ids.control_interface)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user