1894 lines
76 KiB
Rust
1894 lines
76 KiB
Rust
use egui::{
|
||
Align, Align2, Color32, Context, FontId, Grid, Layout, Pos2, Rect, RichText, Rounding,
|
||
ScrollArea, Sense, Stroke, TextEdit, Ui, Vec2,
|
||
};
|
||
use insta360_link_protocol::{
|
||
AudioCaptureMode, CameraModel, CompositionStyle, ExposureCurve, FilterMode, HostPtzInfo,
|
||
ImageOperation, Insta360CameraController, PowerLineFrequency, TrackSpeed, UvcControlTransport,
|
||
Gesture,
|
||
};
|
||
|
||
use crate::state::{
|
||
BackgroundEffectMode, CameraAppState, ColorPresetSlot, PtzPresetSlot, RightSidebarTab,
|
||
ISO_LIST, SHUTTER_DENOM_LIST,
|
||
};
|
||
use crate::theme::{
|
||
ins_card, ins_checkbox_row, ins_section_header, ins_slider_f32, ins_slider_i32,
|
||
ins_switch_row, ins_tab_button, InsTheme,
|
||
};
|
||
use crate::video_engine::VideoPreviewEngine;
|
||
|
||
/// Evaluates a smooth Hermite spline through monotonic control points normalized to [0..1].
|
||
fn evaluate_spline(points: &[(f32, f32)], t: f32) -> f32 {
|
||
if points.is_empty() {
|
||
return t;
|
||
}
|
||
if points.len() == 1 {
|
||
return points[0].1;
|
||
}
|
||
if t <= points[0].0 {
|
||
return points[0].1;
|
||
}
|
||
if t >= points[points.len() - 1].0 {
|
||
return points[points.len() - 1].1;
|
||
}
|
||
for i in 0..points.len() - 1 {
|
||
let p0 = points[i];
|
||
let p1 = points[i + 1];
|
||
if t >= p0.0 && t <= p1.0 {
|
||
let span = p1.0 - p0.0;
|
||
if span.abs() < 1e-5 {
|
||
return p0.1;
|
||
}
|
||
let alpha = (t - p0.0) / span;
|
||
let s = alpha * alpha * (3.0_f32 - 2.0_f32 * alpha);
|
||
return p0.1 + s * (p1.1 - p0.1);
|
||
}
|
||
}
|
||
t
|
||
}
|
||
|
||
/// Constructs a 128-point hardware ExposureCurve payload from spline points.
|
||
fn build_exposure_curve(points: &[(f32, f32)], mode: u8, iso: u16) -> ExposureCurve {
|
||
let mut curve_points = Vec::with_capacity(128);
|
||
for i in 0..128 {
|
||
let t = i as f32 / 127.0_f32;
|
||
let val = evaluate_spline(points, t).clamp(0.0_f32, 1.0_f32);
|
||
curve_points.push((val * 255.0_f32) as u16);
|
||
}
|
||
ExposureCurve {
|
||
mode,
|
||
iso,
|
||
points: curve_points,
|
||
}
|
||
}
|
||
|
||
/// Renders the exact 284px right-hand sidebar (`Settings.qml`) and its 3 tabs
|
||
/// (`View`, `Effects`, `More`) of the official Insta360 Link Controller v2.0.2 desktop app.
|
||
pub fn render_right_settings_panel<T: UvcControlTransport>(
|
||
ui: &mut Ui,
|
||
ctx: &Context,
|
||
state: &mut CameraAppState,
|
||
ctrl: &mut Insta360CameraController<T>,
|
||
video: &VideoPreviewEngine,
|
||
theme: &InsTheme,
|
||
) {
|
||
ui.set_width(284.0_f32);
|
||
|
||
// ========================================================================
|
||
// 1. Top Tab Bar (`Settings.qml`)
|
||
// ========================================================================
|
||
let tab_bar_rect = ui.allocate_space(Vec2::new(ui.available_width(), 40.0_f32)).1;
|
||
ui.painter().rect_filled(tab_bar_rect, Rounding::same(8.0_f32), theme.bg02);
|
||
|
||
let tab_w = ((tab_bar_rect.width() - 16.0_f32) / 3.0_f32).max(10.0_f32);
|
||
let mut tab_ui = ui.new_child(egui::UiBuilder::new().max_rect(tab_bar_rect));
|
||
tab_ui.horizontal(|ui| {
|
||
ui.add_space(4.0_f32);
|
||
if ins_tab_button(ui, "View", state.right_tab == RightSidebarTab::View, tab_w, theme) {
|
||
state.right_tab = RightSidebarTab::View;
|
||
}
|
||
if ins_tab_button(ui, "Effects", state.right_tab == RightSidebarTab::Effects, tab_w, theme) {
|
||
state.right_tab = RightSidebarTab::Effects;
|
||
}
|
||
if ins_tab_button(ui, "More", state.right_tab == RightSidebarTab::More, tab_w, theme) {
|
||
state.right_tab = RightSidebarTab::More;
|
||
}
|
||
});
|
||
|
||
ui.add_space(8.0_f32);
|
||
|
||
// ========================================================================
|
||
// 2. Scrollable Tab Content (`ScrollArea::vertical()`)
|
||
// ========================================================================
|
||
ScrollArea::vertical()
|
||
.auto_shrink([false, false])
|
||
.show(ui, |ui| match state.right_tab {
|
||
RightSidebarTab::View => render_view_tab(ui, ctx, state, ctrl, video, theme),
|
||
RightSidebarTab::Effects => render_effects_tab(ui, ctx, state, ctrl, video, theme),
|
||
RightSidebarTab::More => render_more_tab(ui, state, ctrl, theme),
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// Tab 1: `RightSidebarTab::View` (`PTZControl.qml`)
|
||
// ============================================================================
|
||
fn render_view_tab<T: UvcControlTransport>(
|
||
ui: &mut Ui,
|
||
_ctx: &Context,
|
||
state: &mut CameraAppState,
|
||
ctrl: &mut Insta360CameraController<T>,
|
||
_video: &VideoPreviewEngine,
|
||
theme: &InsTheme,
|
||
) {
|
||
// ------------------------------------------------------------------------
|
||
// Card 1: `View Adjustment`
|
||
// ------------------------------------------------------------------------
|
||
ins_card(ui, theme, |ui| {
|
||
if ins_section_header(ui, "View Adjustment", theme, Some("Center gimbal & reset zoom")) {
|
||
state.pan_deg = 0.0_f32;
|
||
state.tilt_deg = 0.0_f32;
|
||
state.zoom_x = 1.0_f32;
|
||
state.host_ptz_x = 0.5_f32;
|
||
state.host_ptz_y = 0.5_f32;
|
||
let _ = ctrl.reset_gimbal();
|
||
let _ = ctrl.set_zoom(100);
|
||
}
|
||
|
||
ui.add_space(6.0_f32);
|
||
|
||
ui.horizontal(|ui| {
|
||
// Left Side: Joystick D-Pad (Link / Link 2) or Digital Viewport Crop Box (Link 2C)
|
||
if ctrl.capabilities().support_ptz_drag {
|
||
render_joystick_pad(ui, state, ctrl, theme);
|
||
} else {
|
||
render_viewport_crop_box(ui, state, ctrl, theme);
|
||
}
|
||
|
||
ui.add_space(10.0_f32);
|
||
|
||
// Right Side: Vertical Zoom Rocker (`DefAdjustment.qml`)
|
||
render_zoom_rocker(ui, state, ctrl, theme);
|
||
});
|
||
});
|
||
|
||
ui.add_space(10.0_f32);
|
||
|
||
// ------------------------------------------------------------------------
|
||
// Card 2: `Tracking Settings` / `Framing Settings`
|
||
// ------------------------------------------------------------------------
|
||
ins_card(ui, theme, |ui| {
|
||
let title = if ctrl.capabilities().support_ptz_drag {
|
||
"Tracking Settings"
|
||
} else {
|
||
"Framing Settings"
|
||
};
|
||
ins_section_header(ui, title, theme, None);
|
||
|
||
ui.add_space(6.0_f32);
|
||
|
||
// Pause-Track Area row
|
||
if ctrl.capabilities().support_area_track {
|
||
ui.horizontal(|ui| {
|
||
ui.label(RichText::new("Pause-Track Area").size(13.0_f32).color(theme.text01));
|
||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||
let btn_text = if state.show_pause_track_overlay {
|
||
"Editing..."
|
||
} else {
|
||
"Set"
|
||
};
|
||
let btn_color = if state.show_pause_track_overlay {
|
||
theme.brand_green
|
||
} else {
|
||
theme.text01
|
||
};
|
||
if ui.button(RichText::new(btn_text).size(12.0_f32).color(btn_color)).clicked() {
|
||
state.show_pause_track_overlay = !state.show_pause_track_overlay;
|
||
if state.show_pause_track_overlay {
|
||
let _ = ctrl.enter_track_forbidden_area_setting();
|
||
} else {
|
||
let _ = ctrl.exit_track_forbidden_area_setting();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
ui.add_space(6.0_f32);
|
||
}
|
||
|
||
// Tracking Speed row
|
||
if ctrl.capabilities().support_track_speed {
|
||
ui.horizontal(|ui| {
|
||
ui.label(RichText::new("Tracking Speed").size(13.0_f32).color(theme.text01));
|
||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||
let cur_label = match state.track_speed {
|
||
TrackSpeed::Fast => "Quick",
|
||
TrackSpeed::Normal => "Ordinary",
|
||
TrackSpeed::Slow => "Slow",
|
||
};
|
||
egui::ComboBox::from_id_salt("tracking_speed_combo")
|
||
.selected_text(cur_label)
|
||
.show_ui(ui, |ui| {
|
||
if ui.selectable_value(&mut state.track_speed, TrackSpeed::Fast, "Quick").clicked() {
|
||
let _ = ctrl.set_track_speed(TrackSpeed::Fast);
|
||
}
|
||
if ui.selectable_value(&mut state.track_speed, TrackSpeed::Normal, "Ordinary").clicked() {
|
||
let _ = ctrl.set_track_speed(TrackSpeed::Normal);
|
||
}
|
||
if ui.selectable_value(&mut state.track_speed, TrackSpeed::Slow, "Slow").clicked() {
|
||
let _ = ctrl.set_track_speed(TrackSpeed::Slow);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
ui.add_space(6.0_f32);
|
||
}
|
||
|
||
// Touch Key AI Tracking switch row (Link 1 only)
|
||
if ctrl.model() == CameraModel::Link {
|
||
if ins_switch_row(
|
||
ui,
|
||
"Touch Key AI Tracking",
|
||
Some("Tap the Touch once to enter/exit AI Tracking"),
|
||
&mut state.enable_single_tap_tracking,
|
||
true,
|
||
theme,
|
||
) {
|
||
let _ = ctrl.set_single_tap_tracking_enabled(state.enable_single_tap_tracking);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// Tab 2: `RightSidebarTab::Effects` (`ImageAdjust.qml`)
|
||
// ============================================================================
|
||
fn render_effects_tab<T: UvcControlTransport>(
|
||
ui: &mut Ui,
|
||
ctx: &Context,
|
||
state: &mut CameraAppState,
|
||
ctrl: &mut Insta360CameraController<T>,
|
||
video: &VideoPreviewEngine,
|
||
theme: &InsTheme,
|
||
) {
|
||
// ------------------------------------------------------------------------
|
||
// Card 1: `Color` (`ImageScreen.qml`)
|
||
// ------------------------------------------------------------------------
|
||
ins_card(ui, theme, |ui| {
|
||
if ins_section_header(ui, "Color", theme, Some("Reset color settings")) {
|
||
state.auto_exposure = true;
|
||
state.ev_compensation = 0.0_f32;
|
||
state.auto_focus = true;
|
||
state.auto_white_balance = true;
|
||
state.wb_kelvin = 5000;
|
||
state.brightness = 50;
|
||
state.contrast = 50;
|
||
state.saturation = 50;
|
||
state.sharpness = 50;
|
||
state.hdr_enabled = false;
|
||
state.spline_points = vec![
|
||
(0.0_f32, 0.0_f32),
|
||
(0.33_f32, 0.33_f32),
|
||
(0.67_f32, 0.67_f32),
|
||
(1.0_f32, 1.0_f32),
|
||
];
|
||
|
||
let _ = ctrl.set_auto_exposure(true);
|
||
let _ = ctrl.set_exposure_compensation(0.0_f32);
|
||
let _ = ctrl.set_auto_focus(true);
|
||
let _ = ctrl.set_auto_white_balance(true);
|
||
let _ = ctrl.set_white_balance_temperature(5000);
|
||
let _ = ctrl.set_brightness(50);
|
||
let _ = ctrl.set_contrast(50);
|
||
let _ = ctrl.set_saturation(50);
|
||
let _ = ctrl.set_sharpness(50);
|
||
let _ = ctrl.set_hdr(false);
|
||
let curve = build_exposure_curve(&state.spline_points, 1, 100);
|
||
let _ = ctrl.set_exposure_curve(&curve);
|
||
}
|
||
|
||
ui.add_space(6.0_f32);
|
||
|
||
// Exposure switch row
|
||
if ins_switch_row(
|
||
ui,
|
||
"Exposure",
|
||
Some("Auto Exposure (disabled when HDR is on)"),
|
||
&mut state.auto_exposure,
|
||
!state.hdr_enabled,
|
||
theme,
|
||
) {
|
||
let _ = ctrl.set_auto_exposure(state.auto_exposure);
|
||
}
|
||
|
||
if state.auto_exposure {
|
||
if ins_slider_f32(
|
||
ui,
|
||
Some("Exposure Compensation"),
|
||
&mut state.ev_compensation,
|
||
-3.0_f32,
|
||
3.0_f32,
|
||
0.3_f32,
|
||
1,
|
||
"EV",
|
||
!state.hdr_enabled,
|
||
theme,
|
||
) {
|
||
let _ = ctrl.set_exposure_compensation(state.ev_compensation);
|
||
}
|
||
} else {
|
||
// ISO Slider
|
||
let mut iso_idx = state.iso_index as i32;
|
||
let iso_suffix = format!(" ({})", ISO_LIST.get(state.iso_index).copied().unwrap_or(100));
|
||
if ins_slider_i32(ui, Some("ISO"), &mut iso_idx, 0, 15, &iso_suffix, true, theme) {
|
||
state.iso_index = iso_idx as usize;
|
||
if let Some(&iso_val) = ISO_LIST.get(state.iso_index) {
|
||
let _ = ctrl.set_iso(iso_val);
|
||
}
|
||
}
|
||
|
||
// Shutter Slider
|
||
let mut shutter_idx = state.shutter_index as i32;
|
||
let shutter_suffix = format!(
|
||
" (1/{}s)",
|
||
SHUTTER_DENOM_LIST.get(state.shutter_index).copied().unwrap_or(100)
|
||
);
|
||
if ins_slider_i32(ui, Some("Shutter"), &mut shutter_idx, 0, 19, &shutter_suffix, true, theme) {
|
||
state.shutter_index = shutter_idx as usize;
|
||
if let Some(&shutter_denom) = SHUTTER_DENOM_LIST.get(state.shutter_index) {
|
||
let _ = ctrl.set_shutter_speed(shutter_denom);
|
||
}
|
||
}
|
||
|
||
ui.add_space(6.0_f32);
|
||
|
||
// Exposure Curve (`DefSplineCanvas.qml`)
|
||
render_exposure_curve_canvas(ui, state, ctrl, theme);
|
||
}
|
||
|
||
ui.add_space(6.0_f32);
|
||
|
||
// Auto Focus switch row
|
||
if ins_switch_row(ui, "Auto Focus", None, &mut state.auto_focus, true, theme) {
|
||
let _ = ctrl.set_auto_focus(state.auto_focus);
|
||
}
|
||
if !state.auto_focus {
|
||
let mut focus_val = state.manual_focus as i32;
|
||
if ins_slider_i32(ui, Some("Focus"), &mut focus_val, 0, 100, "%", true, theme) {
|
||
state.manual_focus = focus_val as u16;
|
||
let _ = ctrl.set_focus(state.manual_focus);
|
||
}
|
||
}
|
||
|
||
ui.add_space(6.0_f32);
|
||
|
||
// White Balance switch row
|
||
if ins_switch_row(ui, "White Balance", None, &mut state.auto_white_balance, true, theme) {
|
||
let _ = ctrl.set_auto_white_balance(state.auto_white_balance);
|
||
}
|
||
if !state.auto_white_balance {
|
||
let mut wb_val = state.wb_kelvin as i32;
|
||
if ins_slider_i32(ui, Some("Temperature"), &mut wb_val, 2000, 10000, "K", true, theme) {
|
||
state.wb_kelvin = wb_val as u16;
|
||
let _ = ctrl.set_white_balance_temperature(state.wb_kelvin);
|
||
}
|
||
}
|
||
|
||
ui.add_space(6.0_f32);
|
||
|
||
// More Color Section (Link 1 always shows; Link 2/2C collapsible)
|
||
let show_more = ctrl.model() == CameraModel::Link || state.open_more_color;
|
||
if show_more {
|
||
let mut br_val = state.brightness as i32;
|
||
if ins_slider_i32(ui, Some("Brightness"), &mut br_val, 0, 100, "%", true, theme) {
|
||
state.brightness = br_val as i16;
|
||
let _ = ctrl.set_brightness(state.brightness);
|
||
}
|
||
|
||
let mut ct_val = state.contrast as i32;
|
||
if ins_slider_i32(ui, Some("Contrast"), &mut ct_val, 0, 100, "%", true, theme) {
|
||
state.contrast = ct_val as u16;
|
||
let _ = ctrl.set_contrast(state.contrast);
|
||
}
|
||
|
||
let mut sat_val = state.saturation as i32;
|
||
if ins_slider_i32(ui, Some("Saturation"), &mut sat_val, 0, 100, "%", true, theme) {
|
||
state.saturation = sat_val as u16;
|
||
let _ = ctrl.set_saturation(state.saturation);
|
||
}
|
||
|
||
let mut sh_val = state.sharpness as i32;
|
||
if ins_slider_i32(ui, Some("Sharpness"), &mut sh_val, 0, 100, "%", true, theme) {
|
||
state.sharpness = sh_val as u16;
|
||
let _ = ctrl.set_sharpness(state.sharpness);
|
||
}
|
||
|
||
if ins_switch_row(
|
||
ui,
|
||
"HDR",
|
||
Some("Currently not supported at 4K 50/60fps"),
|
||
&mut state.hdr_enabled,
|
||
true,
|
||
theme,
|
||
) {
|
||
let _ = ctrl.set_hdr(state.hdr_enabled);
|
||
if let Ok(ae) = ctrl.get_auto_exposure() {
|
||
state.auto_exposure = ae;
|
||
}
|
||
if state.auto_exposure {
|
||
if let Ok(ev) = ctrl.get_exposure_compensation() {
|
||
state.ev_compensation = ev;
|
||
}
|
||
} else {
|
||
if let Ok(iso) = ctrl.get_iso() {
|
||
if let Some(idx) = ISO_LIST.iter().position(|&v| v == iso) {
|
||
state.iso_index = idx;
|
||
}
|
||
}
|
||
if let Ok(shutter) = ctrl.get_shutter_speed() {
|
||
if let Some(idx) = SHUTTER_DENOM_LIST.iter().position(|&v| v == shutter) {
|
||
state.shutter_index = idx;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Anti-Flicker combo
|
||
ui.horizontal(|ui| {
|
||
ui.label(RichText::new("Anti-Flicker").size(13.0_f32).color(theme.text01));
|
||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||
let cur_label = match state.anti_flicker {
|
||
PowerLineFrequency::Auto => "Auto",
|
||
PowerLineFrequency::Hz50 => "50Hz",
|
||
PowerLineFrequency::Hz60 => "60Hz",
|
||
PowerLineFrequency::Disabled => "Off",
|
||
};
|
||
egui::ComboBox::from_id_salt("anti_flicker_combo")
|
||
.selected_text(cur_label)
|
||
.show_ui(ui, |ui| {
|
||
if ui.selectable_value(&mut state.anti_flicker, PowerLineFrequency::Auto, "Auto").clicked() {
|
||
let _ = ctrl.set_power_line_frequency(PowerLineFrequency::Auto);
|
||
}
|
||
if ui.selectable_value(&mut state.anti_flicker, PowerLineFrequency::Hz50, "50Hz").clicked() {
|
||
let _ = ctrl.set_power_line_frequency(PowerLineFrequency::Hz50);
|
||
}
|
||
if ui.selectable_value(&mut state.anti_flicker, PowerLineFrequency::Hz60, "60Hz").clicked() {
|
||
let _ = ctrl.set_power_line_frequency(PowerLineFrequency::Hz60);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
if ctrl.model() == CameraModel::Link {
|
||
if ins_switch_row(
|
||
ui,
|
||
"Save Startup Parameters",
|
||
None,
|
||
&mut state.startup_params_enabled,
|
||
true,
|
||
theme,
|
||
) {
|
||
let _ = ctrl.set_startup_params_enabled(state.startup_params_enabled);
|
||
}
|
||
}
|
||
}
|
||
|
||
if ctrl.model() != CameraModel::Link {
|
||
ui.add_space(4.0_f32);
|
||
ui.vertical_centered(|ui| {
|
||
let label = if state.open_more_color {
|
||
"Collapse ▴"
|
||
} else {
|
||
"Show ▾"
|
||
};
|
||
if ui.button(RichText::new(label).size(12.0_f32).color(theme.text02)).clicked() {
|
||
state.open_more_color = !state.open_more_color;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Color Presets Grid (`right_img_color_preset`)
|
||
if ctrl.capabilities().support_image_template {
|
||
ui.add_space(8.0_f32);
|
||
ui.separator();
|
||
ui.add_space(6.0_f32);
|
||
render_color_presets(ui, ctx, state, ctrl, video, theme);
|
||
}
|
||
});
|
||
|
||
// ------------------------------------------------------------------------
|
||
// Card 2: `Background` (`ImageBackground.qml`) (Link 2 / Link 2C only)
|
||
// ------------------------------------------------------------------------
|
||
if ctrl.model() != CameraModel::Link {
|
||
ui.add_space(10.0_f32);
|
||
ins_card(ui, theme, |ui| {
|
||
ins_section_header(ui, "Background", theme, None);
|
||
ui.add_space(6.0_f32);
|
||
|
||
let modes = [
|
||
(BackgroundEffectMode::None, "⊘", "None"),
|
||
(BackgroundEffectMode::StandardBlur, "💧", "Blur"),
|
||
(BackgroundEffectMode::NaturalBokeh, "✨", "Bokeh"),
|
||
(BackgroundEffectMode::CustomImage(0), "🖼", "Office"),
|
||
];
|
||
|
||
ui.horizontal(|ui| {
|
||
for (mode, icon, label) in modes {
|
||
let is_active = state.background_mode == mode;
|
||
let (rect, response) = ui.allocate_exact_size(Vec2::splat(52.0_f32), Sense::click());
|
||
let stroke = if is_active {
|
||
Stroke::new(2.0_f32, theme.brand_green)
|
||
} else if response.hovered() {
|
||
Stroke::new(1.0_f32, theme.text02)
|
||
} else {
|
||
Stroke::new(1.0_f32, theme.border02)
|
||
};
|
||
ui.painter().rect_filled(rect, Rounding::same(8.0_f32), theme.bg03);
|
||
ui.painter().rect_stroke(rect, Rounding::same(8.0_f32), stroke);
|
||
|
||
ui.painter().text(
|
||
Pos2::new(rect.center().x, rect.center().y - 8.0_f32),
|
||
Align2::CENTER_CENTER,
|
||
icon,
|
||
FontId::proportional(18.0_f32),
|
||
if is_active { theme.brand_green } else { theme.text01 },
|
||
);
|
||
ui.painter().text(
|
||
Pos2::new(rect.center().x, rect.center().y + 14.0_f32),
|
||
Align2::CENTER_CENTER,
|
||
label,
|
||
FontId::proportional(10.5_f32),
|
||
if is_active { theme.brand_green } else { theme.text02 },
|
||
);
|
||
|
||
if response.clicked() {
|
||
state.background_mode = mode;
|
||
}
|
||
}
|
||
});
|
||
|
||
if matches!(
|
||
state.background_mode,
|
||
BackgroundEffectMode::StandardBlur | BackgroundEffectMode::NaturalBokeh
|
||
) {
|
||
ui.add_space(8.0_f32);
|
||
let mut blur_val = state.blur_intensity as i32;
|
||
if ins_slider_i32(ui, Some("Intensity"), &mut blur_val, 0, 100, "%", true, theme) {
|
||
state.blur_intensity = blur_val as u8;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// ------------------------------------------------------------------------
|
||
// Card 3: `Makeup` (`ImageBeauty.qml`)
|
||
// ------------------------------------------------------------------------
|
||
ui.add_space(10.0_f32);
|
||
ins_card(ui, theme, |ui| {
|
||
if ins_switch_row(ui, "Makeup & Retouch", None, &mut state.makeup_enabled, true, theme) {
|
||
// Toggled
|
||
}
|
||
if state.makeup_enabled {
|
||
ui.add_space(6.0_f32);
|
||
let mut makeup_val = state.makeup_intensity as i32;
|
||
if ins_slider_i32(ui, Some("Intensity"), &mut makeup_val, 0, 100, "%", true, theme) {
|
||
state.makeup_intensity = makeup_val as u8;
|
||
}
|
||
}
|
||
});
|
||
|
||
// ------------------------------------------------------------------------
|
||
// Card 4: `Filter` (`ImageFilter.qml`)
|
||
// ------------------------------------------------------------------------
|
||
ui.add_space(10.0_f32);
|
||
ins_card(ui, theme, |ui| {
|
||
ins_section_header(ui, "Filter", theme, None);
|
||
ui.add_space(6.0_f32);
|
||
|
||
const FILTERS: [(FilterMode, &str); 9] = [
|
||
(FilterMode::None, "None"),
|
||
(FilterMode::Style1, "Neon"),
|
||
(FilterMode::Style2, "Vintage1"),
|
||
(FilterMode::Amber, "Vintage2"),
|
||
(FilterMode::Film1, "Daylight"),
|
||
(FilterMode::Fresh, "Clear"),
|
||
(FilterMode::Film2, "Portrait"),
|
||
(FilterMode::Cool, "Cool"),
|
||
(FilterMode::Warm, "Warm"),
|
||
];
|
||
|
||
Grid::new("filter_tiles_grid")
|
||
.spacing([8.0_f32, 8.0_f32])
|
||
.show(ui, |ui| {
|
||
let mut col = 0;
|
||
for (mode, label) in FILTERS {
|
||
let is_active = state.filter_mode == mode;
|
||
let (rect, response) =
|
||
ui.allocate_exact_size(Vec2::new(52.0_f32, 68.0_f32), Sense::click());
|
||
let thumb_rect = Rect::from_min_size(rect.min, Vec2::new(52.0_f32, 50.0_f32));
|
||
|
||
let stroke = if is_active {
|
||
Stroke::new(2.0_f32, theme.brand_green)
|
||
} else if response.hovered() {
|
||
Stroke::new(1.0_f32, theme.text02)
|
||
} else {
|
||
Stroke::new(1.0_f32, theme.border02)
|
||
};
|
||
|
||
let (c1, c2) = match mode {
|
||
FilterMode::None => (
|
||
Color32::from_rgb(80, 80, 80),
|
||
Color32::from_rgb(60, 60, 60),
|
||
),
|
||
FilterMode::Style1 => (
|
||
Color32::from_rgb(255, 60, 180),
|
||
Color32::from_rgb(0, 220, 255),
|
||
),
|
||
FilterMode::Style2 => (
|
||
Color32::from_rgb(210, 160, 100),
|
||
Color32::from_rgb(140, 90, 50),
|
||
),
|
||
FilterMode::Amber => (
|
||
Color32::from_rgb(255, 170, 40),
|
||
Color32::from_rgb(180, 90, 20),
|
||
),
|
||
FilterMode::Film1 => (
|
||
Color32::from_rgb(255, 230, 150),
|
||
Color32::from_rgb(100, 180, 240),
|
||
),
|
||
FilterMode::Fresh => (
|
||
Color32::from_rgb(130, 230, 200),
|
||
Color32::from_rgb(70, 160, 200),
|
||
),
|
||
FilterMode::Film2 => (
|
||
Color32::from_rgb(255, 190, 180),
|
||
Color32::from_rgb(200, 130, 150),
|
||
),
|
||
FilterMode::Cool => (
|
||
Color32::from_rgb(80, 160, 255),
|
||
Color32::from_rgb(40, 80, 180),
|
||
),
|
||
FilterMode::Warm => (
|
||
Color32::from_rgb(255, 140, 60),
|
||
Color32::from_rgb(200, 70, 30),
|
||
),
|
||
};
|
||
|
||
ui.painter().rect_filled(thumb_rect, Rounding::same(6.0_f32), c1);
|
||
let sub_rect = Rect::from_min_max(
|
||
Pos2::new(thumb_rect.left(), thumb_rect.center().y),
|
||
thumb_rect.max,
|
||
);
|
||
ui.painter().rect_filled(sub_rect, Rounding::same(6.0_f32), c2);
|
||
ui.painter().rect_stroke(thumb_rect, Rounding::same(6.0_f32), stroke);
|
||
|
||
ui.painter().text(
|
||
Pos2::new(rect.center().x, rect.max.y - 8.0_f32),
|
||
Align2::CENTER_CENTER,
|
||
label,
|
||
FontId::proportional(10.5_f32),
|
||
if is_active {
|
||
theme.brand_green
|
||
} else {
|
||
theme.text02
|
||
},
|
||
);
|
||
|
||
if response.clicked() {
|
||
state.filter_mode = mode;
|
||
let _ = ctrl.set_filter(mode);
|
||
}
|
||
|
||
col += 1;
|
||
if col % 4 == 0 {
|
||
ui.end_row();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// Tab 3: `RightSidebarTab::More` (`MoreSettings.qml`)
|
||
// ============================================================================
|
||
fn render_more_tab<T: UvcControlTransport>(
|
||
ui: &mut Ui,
|
||
state: &mut CameraAppState,
|
||
ctrl: &mut Insta360CameraController<T>,
|
||
theme: &InsTheme,
|
||
) {
|
||
// ------------------------------------------------------------------------
|
||
// Card 1: `Gesture`
|
||
// ------------------------------------------------------------------------
|
||
ins_card(ui, theme, |ui| {
|
||
if ins_switch_row(ui, "Gesture", None, &mut state.enable_all_gestures, true, theme) {
|
||
let _ = ctrl.set_all_gestures_enabled(state.enable_all_gestures);
|
||
}
|
||
|
||
ui.add_space(4.0_f32);
|
||
|
||
let tracking_label = if ctrl.capabilities().support_ptz_drag {
|
||
"AI Tracking"
|
||
} else {
|
||
"Auto Framing"
|
||
};
|
||
if ins_checkbox_row(
|
||
ui,
|
||
tracking_label,
|
||
Some("✋"),
|
||
None,
|
||
&mut state.use_gesture_palm,
|
||
state.enable_all_gestures,
|
||
theme,
|
||
) {
|
||
let _ = ctrl.set_gesture_enabled(Gesture::Palm, state.use_gesture_palm);
|
||
}
|
||
|
||
if ins_checkbox_row(
|
||
ui,
|
||
"Whiteboard",
|
||
Some("✌"),
|
||
None,
|
||
&mut state.use_gesture_v,
|
||
state.enable_all_gestures,
|
||
theme,
|
||
) {
|
||
let _ = ctrl.set_gesture_enabled(Gesture::VShape, state.use_gesture_v);
|
||
}
|
||
|
||
if ins_checkbox_row(
|
||
ui,
|
||
"Zoom",
|
||
Some("👆"),
|
||
None,
|
||
&mut state.use_gesture_l,
|
||
state.enable_all_gestures,
|
||
theme,
|
||
) {
|
||
let _ = ctrl.set_gesture_enabled(Gesture::LShape, state.use_gesture_l);
|
||
}
|
||
});
|
||
|
||
ui.add_space(10.0_f32);
|
||
|
||
// ------------------------------------------------------------------------
|
||
// Card 2: `Audio Mode`
|
||
// ------------------------------------------------------------------------
|
||
ins_card(ui, theme, |ui| {
|
||
ins_section_header(ui, "Audio Mode", theme, None);
|
||
ui.add_space(6.0_f32);
|
||
|
||
if ctrl.capabilities().support_audio_capture_mode {
|
||
let mut modes = vec![
|
||
(AudioCaptureMode::VoiceFocus, "Voice Focus"),
|
||
(AudioCaptureMode::VoiceSuppression, "Voice Suppression"),
|
||
(AudioCaptureMode::MusicBalance, "Music Balance"),
|
||
];
|
||
if ctrl.capabilities().support_sound_wall {
|
||
modes.push((AudioCaptureMode::SoundWall, "Sound Wall"));
|
||
}
|
||
|
||
for (mode, label) in modes {
|
||
let is_selected = state.audio_capture_mode == mode;
|
||
let (rect, response) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 24.0_f32), Sense::click());
|
||
|
||
ui.painter().text(
|
||
Pos2::new(rect.left(), rect.center().y),
|
||
Align2::LEFT_CENTER,
|
||
label,
|
||
FontId::proportional(13.0_f32),
|
||
if is_selected { theme.text01 } else { theme.text02 },
|
||
);
|
||
|
||
let radio_center = Pos2::new(rect.right() - 10.0_f32, rect.center().y);
|
||
if is_selected {
|
||
ui.painter().circle_stroke(radio_center, 7.0_f32, Stroke::new(1.5_f32, theme.brand_green));
|
||
ui.painter().circle_filled(radio_center, 4.0_f32, theme.brand_green);
|
||
} else {
|
||
ui.painter().circle_stroke(radio_center, 7.0_f32, Stroke::new(1.0_f32, theme.border02));
|
||
}
|
||
|
||
if response.clicked() {
|
||
state.audio_capture_mode = mode;
|
||
let _ = ctrl.set_audio_capture_mode(mode);
|
||
}
|
||
}
|
||
} else if ins_switch_row(
|
||
ui,
|
||
"AI Audio Noise Reduction",
|
||
None,
|
||
&mut state.audio_denoise,
|
||
true,
|
||
theme,
|
||
) {
|
||
let _ = ctrl.set_audio_noise_reduction(state.audio_denoise);
|
||
}
|
||
});
|
||
|
||
ui.add_space(10.0_f32);
|
||
|
||
// ------------------------------------------------------------------------
|
||
// Card 3: `Other Settings`
|
||
// ------------------------------------------------------------------------
|
||
ins_card(ui, theme, |ui| {
|
||
ins_section_header(ui, "Other Settings", theme, None);
|
||
ui.add_space(6.0_f32);
|
||
|
||
if ins_checkbox_row(ui, "Horizontal Flip", None, None, &mut state.horizontal_mirror, true, theme) {
|
||
let _ = ctrl.set_horizontal_mirror(state.horizontal_mirror);
|
||
}
|
||
|
||
if ctrl.capabilities().support_mirror_vertically {
|
||
if ins_checkbox_row(ui, "Vertical Flip", None, None, &mut state.vertical_mirror, true, theme) {
|
||
let _ = ctrl.set_vertical_mirror(state.vertical_mirror);
|
||
}
|
||
}
|
||
|
||
if ctrl.capabilities().support_smart_adjustment {
|
||
if ins_checkbox_row(
|
||
ui,
|
||
"Smart Adjustment",
|
||
None,
|
||
Some("Automatically search for your head within a small range and adjust to the optimal angle."),
|
||
&mut state.smart_adjustment,
|
||
true,
|
||
theme,
|
||
) {
|
||
let _ = ctrl.set_smart_adjustment(state.smart_adjustment);
|
||
}
|
||
}
|
||
|
||
if ctrl.capabilities().support_horizontal_correction {
|
||
let enabled = !state.format_preset.is_4k();
|
||
if ins_checkbox_row(
|
||
ui,
|
||
"Horizontal Correction",
|
||
None,
|
||
Some("Keep the horizon level automatically."),
|
||
&mut state.horizontal_correction,
|
||
enabled,
|
||
theme,
|
||
) {
|
||
let _ = ctrl.set_horizontal_correction(state.horizontal_correction);
|
||
}
|
||
}
|
||
|
||
if ctrl.capabilities().support_roll_adjustment {
|
||
if ins_slider_f32(
|
||
ui,
|
||
Some("Horizontal fine-tuning"),
|
||
&mut state.roll_deg,
|
||
-1.0_f32,
|
||
1.0_f32,
|
||
0.1_f32,
|
||
1,
|
||
"°",
|
||
true,
|
||
theme,
|
||
) {
|
||
let _ = ctrl.set_roll((state.roll_deg * 10.0_f32) as i16);
|
||
}
|
||
}
|
||
});
|
||
|
||
ui.add_space(10.0_f32);
|
||
|
||
// ------------------------------------------------------------------------
|
||
// Card 4: `Compatibility Settings`
|
||
// ------------------------------------------------------------------------
|
||
ins_card(ui, theme, |ui| {
|
||
ins_section_header(ui, "Compatibility Settings", theme, None);
|
||
ui.add_space(6.0_f32);
|
||
|
||
if ins_checkbox_row(
|
||
ui,
|
||
"Portrait Resolution and High Frame Rate",
|
||
None,
|
||
Some("Enable 9:16 portrait resolution output."),
|
||
&mut state.vertical_resolution_mode,
|
||
!state.is_recording,
|
||
theme,
|
||
) {
|
||
let _ = ctrl.set_vertical_screen(state.vertical_resolution_mode);
|
||
if state.vertical_resolution_mode {
|
||
state.format_preset = crate::state::VideoFormatPreset::Vertical1080x1920p30;
|
||
} else {
|
||
state.format_preset = crate::state::VideoFormatPreset::Fhd1080p30;
|
||
}
|
||
}
|
||
|
||
if state.vertical_resolution_mode && ctrl.model() == CameraModel::Link {
|
||
if ins_checkbox_row(
|
||
ui,
|
||
"Lock vertical shot",
|
||
None,
|
||
Some("Lock gimbal at 90° in vertical shooting mode."),
|
||
&mut state.forced_vertical_lock,
|
||
true,
|
||
theme,
|
||
) {
|
||
let _ = ctrl.set_forced_vertical(state.forced_vertical_lock);
|
||
}
|
||
}
|
||
|
||
if ctrl.model() != CameraModel::Link {
|
||
if ins_checkbox_row(
|
||
ui,
|
||
"Enable Low Resolution",
|
||
None,
|
||
Some("Allow 360p resolution selection."),
|
||
&mut state.low_resolution_mode,
|
||
!state.is_recording,
|
||
theme,
|
||
) {
|
||
let _ = ctrl.set_low_resolution(state.low_resolution_mode);
|
||
if state.low_resolution_mode {
|
||
state.format_preset = crate::state::VideoFormatPreset::Sd360p30;
|
||
} else {
|
||
state.format_preset = crate::state::VideoFormatPreset::Fhd1080p30;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
ui.add_space(10.0_f32);
|
||
|
||
// ------------------------------------------------------------------------
|
||
// Card 5: `Device Info & Factory Reset`
|
||
// ------------------------------------------------------------------------
|
||
ins_card(ui, theme, |ui| {
|
||
ins_section_header(ui, "Device Info", theme, None);
|
||
ui.add_space(6.0_f32);
|
||
|
||
ui.horizontal(|ui| {
|
||
ui.label(RichText::new("Firmware version").size(12.0_f32).color(theme.text02));
|
||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||
ui.label(RichText::new(&state.device_info.firmware_version).size(12.0_f32).color(theme.text01));
|
||
});
|
||
});
|
||
ui.add_space(3.0_f32);
|
||
ui.horizontal(|ui| {
|
||
ui.label(RichText::new("Serial number").size(12.0_f32).color(theme.text02));
|
||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||
ui.label(RichText::new(&state.device_info.serial_number).size(12.0_f32).color(theme.text01));
|
||
});
|
||
});
|
||
|
||
ui.add_space(10.0_f32);
|
||
ui.vertical_centered_justified(|ui| {
|
||
if ui.button(RichText::new("Factory reset").size(13.0_f32).color(theme.text02)).clicked() {
|
||
state.show_factory_reset_pop = true;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// ============================================================================
|
||
// Specialized Widgets
|
||
// ============================================================================
|
||
|
||
/// 128×128 Circular Gimbal Joystick / D-Pad (`DefControlPanel.qml`).
|
||
fn render_joystick_pad<T: UvcControlTransport>(
|
||
ui: &mut Ui,
|
||
state: &mut CameraAppState,
|
||
ctrl: &mut Insta360CameraController<T>,
|
||
theme: &InsTheme,
|
||
) {
|
||
let (rect, response) = ui.allocate_exact_size(Vec2::splat(128.0_f32), Sense::click_and_drag());
|
||
let center = rect.center();
|
||
let painter = ui.painter();
|
||
|
||
// Outer circle
|
||
painter.circle_filled(center, 63.0_f32, theme.bg03);
|
||
painter.circle_stroke(center, 63.0_f32, Stroke::new(1.0_f32, theme.border02));
|
||
|
||
// Inner ring
|
||
painter.circle_stroke(center, 38.0_f32, Stroke::new(1.0_f32, theme.border02));
|
||
|
||
// Directional chevrons
|
||
let chev_font = FontId::proportional(12.0_f32);
|
||
painter.text(Pos2::new(center.x, center.y - 48.0_f32), Align2::CENTER_CENTER, "▲", chev_font.clone(), theme.text03);
|
||
painter.text(Pos2::new(center.x, center.y + 48.0_f32), Align2::CENTER_CENTER, "▼", chev_font.clone(), theme.text03);
|
||
painter.text(Pos2::new(center.x - 48.0_f32, center.y), Align2::CENTER_CENTER, "◀", chev_font.clone(), theme.text03);
|
||
painter.text(Pos2::new(center.x + 48.0_f32, center.y), Align2::CENTER_CENTER, "▶", chev_font, theme.text03);
|
||
|
||
// Draggable Center Knob with continuous velocity (`DefControlPanel.qml` + `CameraInsta::movePanTilt`)
|
||
let mut knob_pos = center;
|
||
let is_holding = response.dragged() || response.is_pointer_button_down_on();
|
||
|
||
if is_holding {
|
||
state.joystick_dragging = true;
|
||
if let Some(ptr) = response.interact_pointer_pos() {
|
||
let diff = ptr - center;
|
||
let offset = if diff.length() > 38.0_f32 {
|
||
diff.normalized() * 38.0_f32
|
||
} else {
|
||
diff
|
||
};
|
||
knob_pos = center + offset;
|
||
|
||
let deadzone = 8.0_f32;
|
||
let (pan_speed, pan_dir) = if diff.x.abs() > deadzone {
|
||
let norm = ((diff.x.abs() - deadzone) / 30.0_f32).clamp(0.0_f32, 1.0_f32);
|
||
let spd = ((norm * 6.0_f32 + 1.0_f32).round() as u8).clamp(1, 7);
|
||
let dir = if diff.x > 0.0_f32 { 1i8 } else { -1i8 };
|
||
(spd, dir)
|
||
} else {
|
||
(0u8, 0i8)
|
||
};
|
||
|
||
let (tilt_speed, tilt_dir) = if diff.y.abs() > deadzone {
|
||
let norm = ((diff.y.abs() - deadzone) / 30.0_f32).clamp(0.0_f32, 1.0_f32);
|
||
let spd = ((norm * 6.0_f32 + 1.0_f32).round() as u8).clamp(1, 7);
|
||
// Screen Y < 0 (up) -> tilt_dir = -1 (0xff); Screen Y > 0 (down) -> tilt_dir = 1 (0x01)
|
||
let dir = if diff.y > 0.0_f32 { 1i8 } else { -1i8 };
|
||
(spd, dir)
|
||
} else {
|
||
(0u8, 0i8)
|
||
};
|
||
|
||
// Update visual preview angles smoothly for simulator
|
||
state.pan_deg = (state.pan_deg + (pan_dir as f32) * (pan_speed as f32) * 0.12_f32).clamp(-130.0_f32, 130.0_f32);
|
||
state.tilt_deg = (state.tilt_deg - (tilt_dir as f32) * (tilt_speed as f32) * 0.12_f32).clamp(-90.0_f32, 90.0_f32);
|
||
|
||
let cmd = (pan_speed, pan_dir, tilt_speed, tilt_dir);
|
||
let can_send = state
|
||
.last_ptz_send_time
|
||
.map(|t| t.elapsed().as_millis() >= 70)
|
||
.unwrap_or(true);
|
||
if cmd != state.last_ptz_cmd && (can_send || cmd == (0, 0, 0, 0)) {
|
||
state.nudge_stop_at = None;
|
||
let _ = ctrl.set_pantilt_relative_ext(pan_speed, pan_dir, tilt_speed, tilt_dir);
|
||
state.last_ptz_cmd = cmd;
|
||
state.last_ptz_send_time = Some(std::time::Instant::now());
|
||
}
|
||
}
|
||
} else if response.clicked() {
|
||
// Short click on one of the 4 directional sectors (`DefControlPanel.qml` `addPanTilt`)
|
||
if let Some(ptr) = response.interact_pointer_pos() {
|
||
let diff = ptr - center;
|
||
if diff.length() > 10.0_f32 {
|
||
let (pan_speed, pan_dir, tilt_speed, tilt_dir) = if diff.x.abs() >= diff.y.abs() {
|
||
let dir = if diff.x > 0.0_f32 { 1i8 } else { -1i8 };
|
||
state.pan_deg = (state.pan_deg + (dir as f32) * 1.5_f32).clamp(-130.0_f32, 130.0_f32);
|
||
(4u8, dir, 0u8, 0i8)
|
||
} else {
|
||
let dir = if diff.y > 0.0_f32 { 1i8 } else { -1i8 };
|
||
state.tilt_deg = (state.tilt_deg - (dir as f32) * 1.5_f32).clamp(-90.0_f32, 90.0_f32);
|
||
(0u8, 0i8, 4u8, dir)
|
||
};
|
||
let _ = ctrl.set_pantilt_relative_ext(pan_speed, pan_dir, tilt_speed, tilt_dir);
|
||
state.last_ptz_cmd = (pan_speed, pan_dir, tilt_speed, tilt_dir);
|
||
state.nudge_stop_at =
|
||
Some(std::time::Instant::now() + std::time::Duration::from_millis(120));
|
||
}
|
||
}
|
||
} else if state.joystick_dragging {
|
||
// Released drag: immediately stop gimbal movement (`[0, 1, 0, 1]`)
|
||
state.joystick_dragging = false;
|
||
let _ = ctrl.stop_pantilt_relative();
|
||
state.last_ptz_cmd = (0, 0, 0, 0);
|
||
if let Ok((pan_arc, tilt_arc)) = ctrl.get_pantilt_absolute() {
|
||
state.pan_deg = (pan_arc as f32 / 3600.0_f32).clamp(-130.0_f32, 130.0_f32);
|
||
state.tilt_deg = (tilt_arc as f32 / 3600.0_f32).clamp(-90.0_f32, 90.0_f32);
|
||
}
|
||
}
|
||
|
||
let knob_stroke = if response.dragged() || response.hovered() {
|
||
Stroke::new(1.5_f32, theme.brand_green)
|
||
} else {
|
||
Stroke::new(1.0_f32, theme.border02)
|
||
};
|
||
painter.circle_filled(knob_pos, 20.0_f32, theme.bg04);
|
||
painter.circle_stroke(knob_pos, 20.0_f32, knob_stroke);
|
||
painter.circle_filled(knob_pos, 3.0_f32, if response.dragged() { theme.brand_green } else { theme.text03 });
|
||
}
|
||
|
||
/// 168×128 Viewport Crop Box (`bottomPic` / `selectPic`) for Link 2C fixed lens.
|
||
fn render_viewport_crop_box<T: UvcControlTransport>(
|
||
ui: &mut Ui,
|
||
state: &mut CameraAppState,
|
||
ctrl: &mut Insta360CameraController<T>,
|
||
theme: &InsTheme,
|
||
) {
|
||
let (rect, response) = ui.allocate_exact_size(Vec2::new(168.0_f32, 128.0_f32), Sense::click_and_drag());
|
||
let painter = ui.painter();
|
||
|
||
// Outer frame
|
||
painter.rect_filled(rect, Rounding::same(6.0_f32), theme.bg03);
|
||
painter.rect_stroke(rect, Rounding::same(6.0_f32), Stroke::new(1.0_f32, theme.border02));
|
||
|
||
if state.zoom_x <= 1.01_f32 {
|
||
painter.text(
|
||
Pos2::new(rect.center().x, rect.center().y - 10.0_f32),
|
||
Align2::CENTER_CENTER,
|
||
"⛶",
|
||
FontId::proportional(22.0_f32),
|
||
theme.text03,
|
||
);
|
||
painter.text(
|
||
Pos2::new(rect.center().x, rect.center().y + 12.0_f32),
|
||
Align2::CENTER_CENTER,
|
||
"Displayed maximum range",
|
||
FontId::proportional(11.0_f32),
|
||
theme.text03,
|
||
);
|
||
} else {
|
||
let crop_w = rect.width() / state.zoom_x;
|
||
let crop_h = rect.height() / state.zoom_x;
|
||
let min_cx = rect.left() + crop_w * 0.5_f32;
|
||
let max_cx = rect.right() - crop_w * 0.5_f32;
|
||
let min_cy = rect.top() + crop_h * 0.5_f32;
|
||
let max_cy = rect.bottom() - crop_h * 0.5_f32;
|
||
|
||
if response.dragged() {
|
||
let delta = response.drag_delta();
|
||
let span_x = (max_cx - min_cx).max(1.0_f32);
|
||
let span_y = (max_cy - min_cy).max(1.0_f32);
|
||
state.host_ptz_x = (state.host_ptz_x + delta.x / span_x).clamp(0.0_f32, 1.0_f32);
|
||
state.host_ptz_y = (state.host_ptz_y + delta.y / span_y).clamp(0.0_f32, 1.0_f32);
|
||
|
||
let _ = ctrl.set_host_ptz(&HostPtzInfo {
|
||
x: (state.host_ptz_x * 5140.0_f32) as u16,
|
||
y: (state.host_ptz_y * 5140.0_f32) as u16,
|
||
width: (5140.0_f32 / state.zoom_x) as u16,
|
||
height: (5140.0_f32 / state.zoom_x) as u16,
|
||
});
|
||
}
|
||
|
||
let cx = min_cx + state.host_ptz_x * (max_cx - min_cx);
|
||
let cy = min_cy + state.host_ptz_y * (max_cy - min_cy);
|
||
let crop_rect = Rect::from_center_size(Pos2::new(cx, cy), Vec2::new(crop_w, crop_h));
|
||
|
||
painter.rect_filled(
|
||
crop_rect,
|
||
Rounding::same(4.0_f32),
|
||
Color32::from_rgba_premultiplied(0, 208, 120, 25),
|
||
);
|
||
painter.rect_stroke(
|
||
crop_rect,
|
||
Rounding::same(4.0_f32),
|
||
Stroke::new(1.5_f32, theme.brand_green),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Vertical Zoom Rocker (`DefAdjustment.qml`).
|
||
fn render_zoom_rocker<T: UvcControlTransport>(
|
||
ui: &mut Ui,
|
||
state: &mut CameraAppState,
|
||
ctrl: &mut Insta360CameraController<T>,
|
||
theme: &InsTheme,
|
||
) {
|
||
let (rect, _) = ui.allocate_exact_size(Vec2::new(52.0_f32, 124.0_f32), Sense::hover());
|
||
let painter = ui.painter();
|
||
painter.rect_filled(rect, Rounding::same(8.0_f32), theme.bg03);
|
||
painter.rect_stroke(rect, Rounding::same(8.0_f32), Stroke::new(1.0_f32, theme.border02));
|
||
|
||
let mut rocker_ui = ui.new_child(egui::UiBuilder::new().max_rect(rect));
|
||
rocker_ui.vertical_centered(|ui| {
|
||
ui.add_space(2.0_f32);
|
||
// [ + ]
|
||
if ui
|
||
.add_sized(
|
||
Vec2::new(48.0_f32, 34.0_f32),
|
||
egui::Button::new(RichText::new("+").size(18.0_f32).strong().color(theme.text01)).frame(false),
|
||
)
|
||
.clicked()
|
||
{
|
||
state.zoom_x = (state.zoom_x + 0.1_f32).min(4.0_f32);
|
||
let _ = ctrl.set_zoom((state.zoom_x * 100.0_f32) as u16);
|
||
}
|
||
|
||
ui.add_space(8.0_f32);
|
||
|
||
// Center zoom label
|
||
let zoom_color = if state.zoom_x > 1.01_f32 {
|
||
theme.brand_green
|
||
} else {
|
||
theme.text01
|
||
};
|
||
ui.label(RichText::new(format!("{:.1}x", state.zoom_x)).size(13.0_f32).strong().color(zoom_color));
|
||
|
||
ui.add_space(8.0_f32);
|
||
|
||
// [ - ]
|
||
if ui
|
||
.add_sized(
|
||
Vec2::new(48.0_f32, 34.0_f32),
|
||
egui::Button::new(RichText::new("−").size(18.0_f32).strong().color(theme.text01)).frame(false),
|
||
)
|
||
.clicked()
|
||
{
|
||
state.zoom_x = (state.zoom_x - 0.1_f32).max(1.0_f32);
|
||
let _ = ctrl.set_zoom((state.zoom_x * 100.0_f32) as u16);
|
||
}
|
||
});
|
||
}
|
||
|
||
/// PTZ Presets Grid (`gimbal_preset`).
|
||
fn render_ptz_presets<T: UvcControlTransport>(
|
||
ui: &mut Ui,
|
||
ctx: &Context,
|
||
state: &mut CameraAppState,
|
||
ctrl: &mut Insta360CameraController<T>,
|
||
video: &VideoPreviewEngine,
|
||
theme: &InsTheme,
|
||
) {
|
||
ui.horizontal(|ui| {
|
||
ui.label(RichText::new("Presets").size(12.0_f32).strong().color(theme.text03))
|
||
.on_hover_text("Presets will remember the position and mode you added");
|
||
});
|
||
|
||
ui.add_space(6.0_f32);
|
||
|
||
Grid::new("ptz_presets_grid")
|
||
.spacing([8.0_f32, 8.0_f32])
|
||
.show(ui, |ui| {
|
||
let mut col = 0;
|
||
|
||
// First Cell: [ + ] Add Preset tile
|
||
let (add_rect, add_resp) = ui.allocate_exact_size(Vec2::new(52.0_f32, 52.0_f32), Sense::click());
|
||
let add_stroke = if add_resp.hovered() {
|
||
Stroke::new(1.0_f32, theme.brand_green)
|
||
} else {
|
||
Stroke::new(1.0_f32, theme.border02)
|
||
};
|
||
ui.painter().rect_filled(add_rect, Rounding::same(8.0_f32), theme.overlay02);
|
||
ui.painter().rect_stroke(add_rect, Rounding::same(8.0_f32), add_stroke);
|
||
ui.painter().text(
|
||
add_rect.center(),
|
||
Align2::CENTER_CENTER,
|
||
"+",
|
||
FontId::proportional(22.0_f32),
|
||
theme.text01,
|
||
);
|
||
|
||
if add_resp.clicked() {
|
||
let next_id = state
|
||
.ptz_presets
|
||
.iter()
|
||
.position(|s| !s.saved)
|
||
.map(|idx| state.ptz_presets[idx].id)
|
||
.unwrap_or_else(|| state.ptz_presets.len() as u8);
|
||
|
||
if next_id < 10 {
|
||
if let Ok((pan_arc, tilt_arc)) = ctrl.get_pantilt_absolute() {
|
||
state.pan_deg = (pan_arc as f32 / 3600.0_f32).clamp(-130.0_f32, 130.0_f32);
|
||
state.tilt_deg = (tilt_arc as f32 / 3600.0_f32).clamp(-90.0_f32, 90.0_f32);
|
||
}
|
||
if let Ok(z) = ctrl.get_zoom() {
|
||
state.zoom_x = (z as f32 / 100.0_f32).clamp(1.0_f32, 4.0_f32);
|
||
}
|
||
let img = video.capture_thumbnail();
|
||
let tex = ctx.load_texture(
|
||
format!("ptz_preset_{}", next_id),
|
||
img.clone(),
|
||
egui::TextureOptions::LINEAR,
|
||
);
|
||
|
||
if let Some(slot) = state.ptz_presets.iter_mut().find(|s| s.id == next_id) {
|
||
slot.saved = true;
|
||
slot.pan_deg = state.pan_deg;
|
||
slot.tilt_deg = state.tilt_deg;
|
||
slot.zoom_x = state.zoom_x;
|
||
slot.host_x = state.host_ptz_x;
|
||
slot.host_y = state.host_ptz_y;
|
||
slot.mode = state.video_mode;
|
||
slot.thumbnail = Some(tex);
|
||
slot.raw_thumb = Some(img);
|
||
slot.renaming = false;
|
||
state.active_preset_id = Some(slot.id);
|
||
} else {
|
||
let slot = PtzPresetSlot {
|
||
id: next_id,
|
||
name: format!("Preset {}", next_id + 1),
|
||
saved: true,
|
||
pan_deg: state.pan_deg,
|
||
tilt_deg: state.tilt_deg,
|
||
zoom_x: state.zoom_x,
|
||
host_x: state.host_ptz_x,
|
||
host_y: state.host_ptz_y,
|
||
mode: state.video_mode,
|
||
thumbnail: Some(tex),
|
||
raw_thumb: Some(img),
|
||
renaming: false,
|
||
};
|
||
state.ptz_presets.push(slot);
|
||
state.active_preset_id = Some(next_id);
|
||
}
|
||
}
|
||
}
|
||
|
||
col += 1;
|
||
|
||
// Saved Preset Cells
|
||
let mut recall_id = None;
|
||
let mut update_id = None;
|
||
let mut delete_id = None;
|
||
|
||
for slot in state.ptz_presets.iter_mut().filter(|s| s.saved) {
|
||
let is_active = state.active_preset_id == Some(slot.id);
|
||
let (tile_rect, tile_resp) = ui.allocate_exact_size(Vec2::new(52.0_f32, 68.0_f32), Sense::click());
|
||
let box_rect = Rect::from_min_size(tile_rect.min, Vec2::new(52.0_f32, 52.0_f32));
|
||
|
||
// Thumbnail texture upload if missing
|
||
if slot.thumbnail.is_none() {
|
||
if let Some(raw) = &slot.raw_thumb {
|
||
slot.thumbnail = Some(ctx.load_texture(
|
||
format!("ptz_preset_{}", slot.id),
|
||
raw.clone(),
|
||
egui::TextureOptions::LINEAR,
|
||
));
|
||
}
|
||
}
|
||
|
||
if let Some(tex) = &slot.thumbnail {
|
||
ui.painter().image(
|
||
tex.id(),
|
||
box_rect,
|
||
Rect::from_min_max(Pos2::new(0.0_f32, 0.0_f32), Pos2::new(1.0_f32, 1.0_f32)),
|
||
Color32::WHITE,
|
||
);
|
||
} else {
|
||
ui.painter().rect_filled(box_rect, Rounding::same(8.0_f32), theme.bg03);
|
||
ui.painter().text(
|
||
box_rect.center(),
|
||
Align2::CENTER_CENTER,
|
||
"📷",
|
||
FontId::proportional(18.0_f32),
|
||
theme.text03,
|
||
);
|
||
}
|
||
|
||
let border_stroke = if is_active {
|
||
Stroke::new(2.0_f32, theme.brand_green)
|
||
} else if tile_resp.hovered() {
|
||
Stroke::new(1.0_f32, theme.text02)
|
||
} else {
|
||
Stroke::new(1.0_f32, theme.border02)
|
||
};
|
||
ui.painter().rect_stroke(box_rect, Rounding::same(8.0_f32), border_stroke);
|
||
|
||
// Label / rename below
|
||
if slot.renaming {
|
||
let mut name_ui = ui.new_child(
|
||
egui::UiBuilder::new().max_rect(Rect::from_min_size(
|
||
Pos2::new(tile_rect.left(), box_rect.bottom() + 2.0_f32),
|
||
Vec2::new(52.0_f32, 14.0_f32),
|
||
)),
|
||
);
|
||
let resp = name_ui.add(TextEdit::singleline(&mut slot.name).font(FontId::proportional(10.0_f32)));
|
||
if resp.lost_focus() || name_ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||
slot.renaming = false;
|
||
}
|
||
} else {
|
||
ui.painter().text(
|
||
Pos2::new(tile_rect.center().x, box_rect.bottom() + 8.0_f32),
|
||
Align2::CENTER_CENTER,
|
||
&slot.name,
|
||
FontId::proportional(11.0_f32),
|
||
if is_active { theme.brand_green } else { theme.text02 },
|
||
);
|
||
}
|
||
|
||
if tile_resp.clicked() {
|
||
recall_id = Some(slot.id);
|
||
}
|
||
|
||
tile_resp.context_menu(|ui| {
|
||
if ui.button("Update with Current View").clicked() {
|
||
update_id = Some(slot.id);
|
||
ui.close_menu();
|
||
}
|
||
if ui.button("Rename").clicked() {
|
||
slot.renaming = true;
|
||
ui.close_menu();
|
||
}
|
||
if ui.button("Delete").clicked() {
|
||
delete_id = Some(slot.id);
|
||
ui.close_menu();
|
||
}
|
||
});
|
||
|
||
col += 1;
|
||
if col % 4 == 0 {
|
||
ui.end_row();
|
||
}
|
||
}
|
||
|
||
if let Some(id) = recall_id {
|
||
if let Some(slot) = state.ptz_presets.iter().find(|s| s.id == id).cloned() {
|
||
state.pan_deg = slot.pan_deg;
|
||
state.tilt_deg = slot.tilt_deg;
|
||
state.zoom_x = slot.zoom_x;
|
||
state.video_mode = slot.mode;
|
||
state.active_preset_id = Some(slot.id);
|
||
state.ready_auto_track = false;
|
||
let _ = ctrl.set_pantilt_absolute(
|
||
(slot.pan_deg * 3600.0_f32) as i32,
|
||
(slot.tilt_deg * 3600.0_f32) as i32,
|
||
);
|
||
let _ = ctrl.set_zoom((slot.zoom_x * 100.0_f32) as u16);
|
||
}
|
||
}
|
||
|
||
if let Some(id) = update_id {
|
||
if let Ok((pan_arc, tilt_arc)) = ctrl.get_pantilt_absolute() {
|
||
state.pan_deg = (pan_arc as f32 / 3600.0_f32).clamp(-130.0_f32, 130.0_f32);
|
||
state.tilt_deg = (tilt_arc as f32 / 3600.0_f32).clamp(-90.0_f32, 90.0_f32);
|
||
}
|
||
if let Ok(z) = ctrl.get_zoom() {
|
||
state.zoom_x = (z as f32 / 100.0_f32).clamp(1.0_f32, 4.0_f32);
|
||
}
|
||
if let Some(slot) = state.ptz_presets.iter_mut().find(|s| s.id == id) {
|
||
slot.pan_deg = state.pan_deg;
|
||
slot.tilt_deg = state.tilt_deg;
|
||
slot.zoom_x = state.zoom_x;
|
||
slot.host_x = state.host_ptz_x;
|
||
slot.host_y = state.host_ptz_y;
|
||
slot.mode = state.video_mode;
|
||
let img = video.capture_thumbnail();
|
||
let tex = ctx.load_texture(
|
||
format!("ptz_preset_{}", slot.id),
|
||
img.clone(),
|
||
egui::TextureOptions::LINEAR,
|
||
);
|
||
slot.thumbnail = Some(tex);
|
||
slot.raw_thumb = Some(img);
|
||
}
|
||
}
|
||
|
||
if let Some(id) = delete_id {
|
||
if let Some(slot) = state.ptz_presets.iter_mut().find(|s| s.id == id) {
|
||
slot.saved = false;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 3 Visual Composition Cards (`72.0 x 68.0` each).
|
||
fn render_composition_cards<T: UvcControlTransport>(
|
||
ui: &mut Ui,
|
||
state: &mut CameraAppState,
|
||
ctrl: &mut Insta360CameraController<T>,
|
||
theme: &InsTheme,
|
||
) {
|
||
let cards = [
|
||
(CompositionStyle::Head, "Head"),
|
||
(CompositionStyle::HalfBody, "Half Body"),
|
||
(CompositionStyle::WholeBody, "Whole Body"),
|
||
];
|
||
|
||
ui.horizontal(|ui| {
|
||
for (style, label) in cards {
|
||
let is_selected = state.composition_style == style;
|
||
let (rect, resp) = ui.allocate_exact_size(Vec2::new(72.0_f32, 68.0_f32), Sense::click());
|
||
let thumb_rect = Rect::from_min_size(rect.min, Vec2::new(72.0_f32, 48.0_f32));
|
||
|
||
let stroke = if is_selected {
|
||
Stroke::new(2.0_f32, theme.brand_green)
|
||
} else if resp.hovered() {
|
||
Stroke::new(1.0_f32, theme.text02)
|
||
} else {
|
||
Stroke::new(1.0_f32, theme.border02)
|
||
};
|
||
|
||
ui.painter().rect_filled(thumb_rect, Rounding::same(6.0_f32), theme.bg03);
|
||
ui.painter().rect_stroke(thumb_rect, Rounding::same(6.0_f32), stroke);
|
||
|
||
// Silhouette icon
|
||
let cx = thumb_rect.center().x;
|
||
let cy = thumb_rect.center().y;
|
||
let sil_color = if is_selected { theme.brand_green } else { theme.text02 };
|
||
|
||
match style {
|
||
CompositionStyle::Head => {
|
||
ui.painter().circle_filled(Pos2::new(cx, cy - 4.0_f32), 9.0_f32, sil_color);
|
||
ui.painter().rect_filled(
|
||
Rect::from_center_size(Pos2::new(cx, cy + 14.0_f32), Vec2::new(32.0_f32, 12.0_f32)),
|
||
Rounding::same(4.0_f32),
|
||
sil_color,
|
||
);
|
||
}
|
||
CompositionStyle::HalfBody => {
|
||
ui.painter().circle_filled(Pos2::new(cx, cy - 8.0_f32), 6.5_f32, sil_color);
|
||
ui.painter().rect_filled(
|
||
Rect::from_center_size(Pos2::new(cx, cy + 8.0_f32), Vec2::new(22.0_f32, 18.0_f32)),
|
||
Rounding::same(3.0_f32),
|
||
sil_color,
|
||
);
|
||
}
|
||
CompositionStyle::WholeBody | CompositionStyle::Custom => {
|
||
ui.painter().circle_filled(Pos2::new(cx, cy - 12.0_f32), 4.5_f32, sil_color);
|
||
ui.painter().rect_filled(
|
||
Rect::from_center_size(Pos2::new(cx, cy - 1.0_f32), Vec2::new(14.0_f32, 14.0_f32)),
|
||
Rounding::same(2.0_f32),
|
||
sil_color,
|
||
);
|
||
ui.painter().rect_filled(
|
||
Rect::from_center_size(Pos2::new(cx, cy + 12.0_f32), Vec2::new(10.0_f32, 10.0_f32)),
|
||
Rounding::same(1.0_f32),
|
||
sil_color,
|
||
);
|
||
}
|
||
}
|
||
|
||
ui.painter().text(
|
||
Pos2::new(rect.center().x, rect.max.y - 8.0_f32),
|
||
Align2::CENTER_CENTER,
|
||
label,
|
||
FontId::proportional(11.0_f32),
|
||
if is_selected { theme.brand_green } else { theme.text02 },
|
||
);
|
||
|
||
if resp.clicked() {
|
||
if !state.ai_zoom_enabled {
|
||
state.ai_zoom_enabled = true;
|
||
let _ = ctrl.set_ai_zoom(true);
|
||
}
|
||
state.composition_style = style;
|
||
let _ = ctrl.set_composition_style(style);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 236×150 Interactive Exposure Curve Canvas (`DefSplineCanvas.qml`).
|
||
fn render_exposure_curve_canvas<T: UvcControlTransport>(
|
||
ui: &mut Ui,
|
||
state: &mut CameraAppState,
|
||
ctrl: &mut Insta360CameraController<T>,
|
||
theme: &InsTheme,
|
||
) {
|
||
ui.horizontal(|ui| {
|
||
ui.label(RichText::new("Exposure curve").size(13.0_f32).color(theme.text01));
|
||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||
if ui.button(RichText::new("—").size(14.0_f32).color(theme.text02)).clicked() {
|
||
if state.spline_points.len() > 2 {
|
||
state.spline_points.remove(state.spline_points.len() - 2);
|
||
let curve = build_exposure_curve(&state.spline_points, 0, ISO_LIST[state.iso_index]);
|
||
let _ = ctrl.set_exposure_curve(&curve);
|
||
}
|
||
}
|
||
if ui.button(RichText::new("↺").size(14.0_f32).color(theme.text02)).clicked() {
|
||
state.spline_points = vec![
|
||
(0.0_f32, 0.0_f32),
|
||
(0.33_f32, 0.33_f32),
|
||
(0.67_f32, 0.67_f32),
|
||
(1.0_f32, 1.0_f32),
|
||
];
|
||
let curve = build_exposure_curve(&state.spline_points, 0, ISO_LIST[state.iso_index]);
|
||
let _ = ctrl.set_exposure_curve(&curve);
|
||
}
|
||
});
|
||
});
|
||
|
||
ui.add_space(4.0_f32);
|
||
|
||
let (canvas_rect, response) =
|
||
ui.allocate_exact_size(Vec2::new(236.0_f32, 150.0_f32), Sense::click_and_drag());
|
||
let painter = ui.painter();
|
||
|
||
painter.rect_filled(canvas_rect, Rounding::same(8.0_f32), theme.bg03);
|
||
painter.rect_stroke(canvas_rect, Rounding::same(8.0_f32), Stroke::new(1.0_f32, theme.border02));
|
||
|
||
// 4×4 Grid lines
|
||
for i in 1..4 {
|
||
let frac = i as f32 / 4.0_f32;
|
||
let gx = canvas_rect.left() + frac * canvas_rect.width();
|
||
painter.line_segment(
|
||
[Pos2::new(gx, canvas_rect.top()), Pos2::new(gx, canvas_rect.bottom())],
|
||
Stroke::new(1.0_f32, theme.border02),
|
||
);
|
||
let gy = canvas_rect.top() + frac * canvas_rect.height();
|
||
painter.line_segment(
|
||
[Pos2::new(canvas_rect.left(), gy), Pos2::new(canvas_rect.right(), gy)],
|
||
Stroke::new(1.0_f32, theme.border02),
|
||
);
|
||
}
|
||
|
||
// Diagonal reference line
|
||
painter.line_segment(
|
||
[
|
||
Pos2::new(canvas_rect.left(), canvas_rect.bottom()),
|
||
Pos2::new(canvas_rect.right(), canvas_rect.top()),
|
||
],
|
||
Stroke::new(1.0_f32, theme.border02),
|
||
);
|
||
|
||
let mut curve_changed = false;
|
||
|
||
// Drag point or click to add point
|
||
if response.dragged() {
|
||
if let Some(ptr) = response.interact_pointer_pos() {
|
||
let norm_x = ((ptr.x - canvas_rect.left()) / canvas_rect.width()).clamp(0.0_f32, 1.0_f32);
|
||
let norm_y = ((canvas_rect.bottom() - ptr.y) / canvas_rect.height()).clamp(0.0_f32, 1.0_f32);
|
||
|
||
// Find closest control point
|
||
let mut best_idx = 0;
|
||
let mut best_dist = f32::MAX;
|
||
for (idx, &(px, py)) in state.spline_points.iter().enumerate() {
|
||
let dx = px - norm_x;
|
||
let dy = py - norm_y;
|
||
let d = dx * dx + dy * dy;
|
||
if d < best_dist {
|
||
best_dist = d;
|
||
best_idx = idx;
|
||
}
|
||
}
|
||
|
||
if best_dist < 0.05_f32 {
|
||
let n = state.spline_points.len();
|
||
if best_idx == 0 {
|
||
state.spline_points[0] = (0.0_f32, norm_y);
|
||
} else if best_idx == n - 1 {
|
||
state.spline_points[best_idx] = (1.0_f32, norm_y);
|
||
} else {
|
||
let min_x = state.spline_points[best_idx - 1].0 + 0.02_f32;
|
||
let max_x = state.spline_points[best_idx + 1].0 - 0.02_f32;
|
||
state.spline_points[best_idx] = (norm_x.clamp(min_x, max_x), norm_y);
|
||
}
|
||
curve_changed = true;
|
||
}
|
||
}
|
||
} else if response.clicked() {
|
||
if let Some(ptr) = response.interact_pointer_pos() {
|
||
let norm_x = ((ptr.x - canvas_rect.left()) / canvas_rect.width()).clamp(0.0_f32, 1.0_f32);
|
||
let norm_y = ((canvas_rect.bottom() - ptr.y) / canvas_rect.height()).clamp(0.0_f32, 1.0_f32);
|
||
|
||
let is_near = state.spline_points.iter().any(|&(px, py)| {
|
||
let dx = px - norm_x;
|
||
let dy = py - norm_y;
|
||
dx * dx + dy * dy < 0.01_f32
|
||
});
|
||
|
||
if !is_near && state.spline_points.len() < 8 {
|
||
state.spline_points.push((norm_x, norm_y));
|
||
state.spline_points.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
||
curve_changed = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
if curve_changed {
|
||
let curve = build_exposure_curve(&state.spline_points, 0, ISO_LIST[state.iso_index]);
|
||
let _ = ctrl.set_exposure_curve(&curve);
|
||
}
|
||
|
||
// Draw spline curve
|
||
let mut prev_pt = None;
|
||
for step in 0..=80 {
|
||
let t = step as f32 / 80.0_f32;
|
||
let val = evaluate_spline(&state.spline_points, t).clamp(0.0_f32, 1.0_f32);
|
||
let pt = Pos2::new(
|
||
canvas_rect.left() + t * canvas_rect.width(),
|
||
canvas_rect.bottom() - val * canvas_rect.height(),
|
||
);
|
||
if let Some(prev) = prev_pt {
|
||
painter.line_segment([prev, pt], Stroke::new(2.0_f32, theme.brand_green));
|
||
}
|
||
prev_pt = Some(pt);
|
||
}
|
||
|
||
// Draw control point circles
|
||
for &(px, py) in &state.spline_points {
|
||
let pt = Pos2::new(
|
||
canvas_rect.left() + px * canvas_rect.width(),
|
||
canvas_rect.bottom() - py * canvas_rect.height(),
|
||
);
|
||
painter.circle_filled(pt, 5.0_f32, theme.bg04);
|
||
painter.circle_stroke(pt, 5.0_f32, Stroke::new(1.5_f32, theme.brand_green));
|
||
}
|
||
}
|
||
|
||
/// Color Presets Grid (`right_img_color_preset`).
|
||
fn render_color_presets<T: UvcControlTransport>(
|
||
ui: &mut Ui,
|
||
ctx: &Context,
|
||
state: &mut CameraAppState,
|
||
ctrl: &mut Insta360CameraController<T>,
|
||
video: &VideoPreviewEngine,
|
||
theme: &InsTheme,
|
||
) {
|
||
ui.horizontal(|ui| {
|
||
ui.label(RichText::new("Color Presets").size(12.0_f32).strong().color(theme.text03));
|
||
});
|
||
|
||
ui.add_space(6.0_f32);
|
||
|
||
Grid::new("color_presets_grid")
|
||
.spacing([8.0_f32, 8.0_f32])
|
||
.show(ui, |ui| {
|
||
let mut col = 0;
|
||
|
||
// [ + ] Add Color Preset tile
|
||
let (add_rect, add_resp) = ui.allocate_exact_size(Vec2::new(52.0_f32, 52.0_f32), Sense::click());
|
||
let add_stroke = if add_resp.hovered() {
|
||
Stroke::new(1.0_f32, theme.brand_green)
|
||
} else {
|
||
Stroke::new(1.0_f32, theme.border02)
|
||
};
|
||
ui.painter().rect_filled(add_rect, Rounding::same(8.0_f32), theme.overlay02);
|
||
ui.painter().rect_stroke(add_rect, Rounding::same(8.0_f32), add_stroke);
|
||
ui.painter().text(
|
||
add_rect.center(),
|
||
Align2::CENTER_CENTER,
|
||
"+",
|
||
FontId::proportional(22.0_f32),
|
||
theme.text01,
|
||
);
|
||
|
||
if add_resp.clicked() {
|
||
let next_id = (state.color_presets.len() as i8) + 1;
|
||
let img = video.capture_thumbnail();
|
||
let tex = ctx.load_texture(
|
||
format!("color_preset_{}", next_id),
|
||
img.clone(),
|
||
egui::TextureOptions::LINEAR,
|
||
);
|
||
|
||
state.color_presets.push(ColorPresetSlot {
|
||
id: next_id,
|
||
name: format!("Preset {}", next_id),
|
||
saved: true,
|
||
brightness: state.brightness,
|
||
contrast: state.contrast,
|
||
saturation: state.saturation,
|
||
sharpness: state.sharpness,
|
||
wb_auto: state.auto_white_balance,
|
||
wb_kelvin: state.wb_kelvin,
|
||
auto_exposure: state.auto_exposure,
|
||
ev_comp: state.ev_compensation,
|
||
iso: ISO_LIST.get(state.iso_index).copied().unwrap_or(100),
|
||
shutter: SHUTTER_DENOM_LIST.get(state.shutter_index).copied().unwrap_or(100),
|
||
hdr: state.hdr_enabled,
|
||
thumbnail: Some(tex),
|
||
raw_thumb: Some(img),
|
||
renaming: false,
|
||
});
|
||
let _ = ctrl.set_image_template(next_id, ImageOperation::SaveOrUpdate);
|
||
}
|
||
|
||
col += 1;
|
||
|
||
// Saved Color Preset Slots
|
||
let mut apply_id = None;
|
||
let mut update_id = None;
|
||
let mut delete_id = None;
|
||
|
||
for slot in state.color_presets.iter_mut().filter(|s| s.saved) {
|
||
let is_active = state.active_color_preset_id == Some(slot.id);
|
||
let (tile_rect, tile_resp) = ui.allocate_exact_size(Vec2::new(52.0_f32, 68.0_f32), Sense::click());
|
||
let box_rect = Rect::from_min_size(tile_rect.min, Vec2::new(52.0_f32, 52.0_f32));
|
||
|
||
if slot.thumbnail.is_none() {
|
||
if let Some(raw) = &slot.raw_thumb {
|
||
slot.thumbnail = Some(ctx.load_texture(
|
||
format!("color_preset_{}", slot.id),
|
||
raw.clone(),
|
||
egui::TextureOptions::LINEAR,
|
||
));
|
||
}
|
||
}
|
||
|
||
if let Some(tex) = &slot.thumbnail {
|
||
ui.painter().image(
|
||
tex.id(),
|
||
box_rect,
|
||
Rect::from_min_max(Pos2::new(0.0_f32, 0.0_f32), Pos2::new(1.0_f32, 1.0_f32)),
|
||
Color32::WHITE,
|
||
);
|
||
} else {
|
||
ui.painter().rect_filled(box_rect, Rounding::same(8.0_f32), theme.bg03);
|
||
ui.painter().text(
|
||
box_rect.center(),
|
||
Align2::CENTER_CENTER,
|
||
"🎨",
|
||
FontId::proportional(18.0_f32),
|
||
theme.text03,
|
||
);
|
||
}
|
||
|
||
let border_stroke = if is_active {
|
||
Stroke::new(2.0_f32, theme.brand_green)
|
||
} else if tile_resp.hovered() {
|
||
Stroke::new(1.0_f32, theme.text02)
|
||
} else {
|
||
Stroke::new(1.0_f32, theme.border02)
|
||
};
|
||
ui.painter().rect_stroke(box_rect, Rounding::same(8.0_f32), border_stroke);
|
||
|
||
if slot.renaming {
|
||
let mut name_ui = ui.new_child(
|
||
egui::UiBuilder::new().max_rect(Rect::from_min_size(
|
||
Pos2::new(tile_rect.left(), box_rect.bottom() + 2.0_f32),
|
||
Vec2::new(52.0_f32, 14.0_f32),
|
||
)),
|
||
);
|
||
let resp = name_ui.add(TextEdit::singleline(&mut slot.name).font(FontId::proportional(10.0_f32)));
|
||
if resp.lost_focus() || name_ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||
slot.renaming = false;
|
||
}
|
||
} else {
|
||
ui.painter().text(
|
||
Pos2::new(tile_rect.center().x, box_rect.bottom() + 8.0_f32),
|
||
Align2::CENTER_CENTER,
|
||
&slot.name,
|
||
FontId::proportional(11.0_f32),
|
||
if is_active { theme.brand_green } else { theme.text02 },
|
||
);
|
||
}
|
||
|
||
if tile_resp.clicked() {
|
||
apply_id = Some(slot.id);
|
||
}
|
||
|
||
tile_resp.context_menu(|ui| {
|
||
if ui.button("Update").clicked() {
|
||
update_id = Some(slot.id);
|
||
ui.close_menu();
|
||
}
|
||
if ui.button("Rename").clicked() {
|
||
slot.renaming = true;
|
||
ui.close_menu();
|
||
}
|
||
if ui.button("Delete").clicked() {
|
||
delete_id = Some(slot.id);
|
||
ui.close_menu();
|
||
}
|
||
});
|
||
|
||
col += 1;
|
||
if col % 4 == 0 {
|
||
ui.end_row();
|
||
}
|
||
}
|
||
|
||
if let Some(id) = apply_id {
|
||
if let Some(slot) = state.color_presets.iter().find(|s| s.id == id) {
|
||
state.active_color_preset_id = Some(slot.id);
|
||
state.brightness = slot.brightness;
|
||
state.contrast = slot.contrast;
|
||
state.saturation = slot.saturation;
|
||
state.sharpness = slot.sharpness;
|
||
state.auto_white_balance = slot.wb_auto;
|
||
state.wb_kelvin = slot.wb_kelvin;
|
||
state.auto_exposure = slot.auto_exposure;
|
||
state.ev_compensation = slot.ev_comp;
|
||
state.hdr_enabled = slot.hdr;
|
||
let _ = ctrl.set_image_template(slot.id, ImageOperation::Apply);
|
||
}
|
||
}
|
||
|
||
if let Some(id) = update_id {
|
||
if let Some(slot) = state.color_presets.iter_mut().find(|s| s.id == id) {
|
||
slot.brightness = state.brightness;
|
||
slot.contrast = state.contrast;
|
||
slot.saturation = state.saturation;
|
||
slot.sharpness = state.sharpness;
|
||
slot.wb_auto = state.auto_white_balance;
|
||
slot.wb_kelvin = state.wb_kelvin;
|
||
slot.auto_exposure = state.auto_exposure;
|
||
slot.ev_comp = state.ev_compensation;
|
||
slot.hdr = state.hdr_enabled;
|
||
let img = video.capture_thumbnail();
|
||
let tex = ctx.load_texture(
|
||
format!("color_preset_{}", slot.id),
|
||
img.clone(),
|
||
egui::TextureOptions::LINEAR,
|
||
);
|
||
slot.thumbnail = Some(tex);
|
||
slot.raw_thumb = Some(img);
|
||
let _ = ctrl.set_image_template(slot.id, ImageOperation::SaveOrUpdate);
|
||
}
|
||
}
|
||
|
||
if let Some(id) = delete_id {
|
||
if let Some(slot) = state.color_presets.iter_mut().find(|s| s.id == id) {
|
||
slot.saved = false;
|
||
let _ = ctrl.delete_image_template(slot.id);
|
||
}
|
||
}
|
||
});
|
||
}
|