diff --git a/README.md b/README.md
index d5a97a2..88f3382 100644
--- a/README.md
+++ b/README.md
@@ -124,8 +124,10 @@ Other useful cursor fields are:
| ----------------------------------------------- | -------------------- |
| Ctrl +Alt +C | Copy selection |
| Ctrl +Alt +V | Paste clipboard |
-| Ctrl +Alt +Enter | Toggle 2D / 3D mode |
+| Ctrl +Alt +Enter | Toggle 2D / ortho 3D |
+| Ctrl +Alt +P | Toggle perspective |
| Ctrl +Alt +M | Toggle Mobius mode |
+| Ctrl +Alt +Shift +0-9 | Activate camera slot |
| Ctrl +Alt +Up | Increase warp |
| Ctrl +Alt +Down | Decrease warp |
| Alt +PageUp | Scroll one page up |
@@ -136,6 +138,14 @@ Other useful cursor fields are:
| Ctrl +- | Decrease font size |
| Ctrl +Alt +0 | Reset font size |
+In custom configs, `ToggleOrtho3DMode` is the current action name for the
+orthographic 3D toggle. The old `Toggle3DMode` action remains supported as a
+backward-compatible alias.
+
+Camera slot actions are named `ActivateCameraSlot0` through
+`ActivateCameraSlot9`. The extra Shift modifier keeps slot 0 distinct
+from the existing Ctrl +Alt +0 font reset.
+
## Inline 3D objects
Ratty uses its own protocol, the [Ratty Graphics Protocol](protocols/graphics.md),
@@ -146,6 +156,7 @@ RGP supports:
- registering `.obj`, `.glb`, and `.stl` assets by path
- placing them at terminal cell anchors
- animation, scale, color, depth and other attributes
+- camera control for flat, orthographic, perspective and Mobius views
There is a Ratatui widget called `ratatui-rgp` available in
[`widget/`](widget/) if you want to build your own terminal applications that involve inline 3D objects.
@@ -154,7 +165,9 @@ There is a Ratatui widget called `ratatui-rgp` available in
#### [Big rat](widget/examples/big_rat.rs)
-Places a single oversized rat directly in your terminal:
+Places a single oversized rat directly in your terminal. Press `v` in the demo
+to cycle the Ratty camera protocol through flat, orthographic, perspective and
+Mobius views.
diff --git a/config/ratty.toml b/config/ratty.toml
index 58c9545..5f31c56 100644
--- a/config/ratty.toml
+++ b/config/ratty.toml
@@ -49,8 +49,19 @@ keys = [
{ key = "Equal", with = "Control", action = "IncreaseFontSize" },
{ key = "Minus", with = "Control", action = "DecreaseFontSize" },
{ key = "Digit0", with = "Control | alt", action = "ResetFontSize" },
- { key = "Enter", with = "Control | alt", action = "Toggle3DMode" },
+ { key = "Enter", with = "Control | alt", action = "ToggleOrtho3DMode" },
+ { key = "P", with = "Control | alt", action = "TogglePersp3DMode" },
{ key = "M", with = "Control | alt", action = "ToggleMobiusMode" },
+ { key = "Digit0", with = "Control | alt | shift", action = "ActivateCameraSlot0" },
+ { key = "Digit1", with = "Control | alt | shift", action = "ActivateCameraSlot1" },
+ { key = "Digit2", with = "Control | alt | shift", action = "ActivateCameraSlot2" },
+ { key = "Digit3", with = "Control | alt | shift", action = "ActivateCameraSlot3" },
+ { key = "Digit4", with = "Control | alt | shift", action = "ActivateCameraSlot4" },
+ { key = "Digit5", with = "Control | alt | shift", action = "ActivateCameraSlot5" },
+ { key = "Digit6", with = "Control | alt | shift", action = "ActivateCameraSlot6" },
+ { key = "Digit7", with = "Control | alt | shift", action = "ActivateCameraSlot7" },
+ { key = "Digit8", with = "Control | alt | shift", action = "ActivateCameraSlot8" },
+ { key = "Digit9", with = "Control | alt | shift", action = "ActivateCameraSlot9" },
{ key = "Up", with = "Control | alt", action = "IncreaseWarp" },
{ key = "Down", with = "Control | alt", action = "DecreaseWarp" },
]
diff --git a/protocols/graphics.md b/protocols/graphics.md
index a5f4d4a..fffbcb9 100644
--- a/protocols/graphics.md
+++ b/protocols/graphics.md
@@ -51,6 +51,7 @@ Each object has:
- `p` [place object](#3-place-object)
- `u` [update object](#4-update-object)
- `d` [delete object](#5-delete-object)
+- `c` [camera control](#6-camera-control)
### 1. Support Query
@@ -65,7 +66,7 @@ ESC _ ratty;g;s ESC \
Ratty replies:
```text
-ESC _ ratty;g;s;v=1;fmt=obj|glb|stl;path=1;payload=1;chunk=1;anim=1;depth=1;color=1;brightness=1;transform=1;update=1;normalize=1 ESC \
+ESC _ ratty;g;s;v=1;fmt=obj|glb|stl;path=1;payload=1;chunk=1;anim=1;depth=1;color=1;brightness=1;transform=1;update=1;normalize=1;camera=1 ESC \
```
Fields:
@@ -79,6 +80,7 @@ Fields:
- `depth=1`: `depth=
` placement is supported
- `color=1`: `color=` placement is supported
- `brightness=1`: `brightness=` placement is supported
+- `camera=1`: `c` camera updates are supported
- `transform=1`: transform fields such as rotation and offsets are supported
- `update=1`: `u` object updates are supported
- `normalize=1`: `normalize=<0|1>` registration is supported for OBJ assets
@@ -219,6 +221,46 @@ Delete all Ratty graphics objects:
ESC _ ratty;g;d ESC \
```
+### 6. Camera control
+
+Partially updates one of ten persistent camera presets. `id` is required and
+must be a decimal slot number from `0` through `9`.
+
+```text
+ESC _ ratty;g;c;id=0;set=0;type=Ortho;px=0.25;scale=1.0 ESC \
+ESC _ ratty;g;c;id=1;set=1;type=Persp;fov=50;rx=10;ry=20 ESC \
+```
+
+- `set`: `0` or `1`; when `1`, activates the slot after applying this update.
+ Defaults to `0`.
+
+Optional fields retain their previous values when omitted:
+
+- `type`: one of `Flat`, `Ortho`, `Persp`, `Mobius`.
+- `scale`: orthographic projection scale of at least `0.01`. The value is used
+ when the slot presents in `Ortho` or `Mobius`.
+- `fov`: vertical perspective field of view in degrees, used when the slot
+ presents in `Persp`. The value must be strictly inside the range equivalent
+ to `0.05` through `pi - 0.05` radians (about `2.87` through `177.13`
+ degrees).
+- `px`, `py`, `pz`: horizontal pan, vertical pan, and camera dolly relative to
+ the default camera position.
+- `rx`, `ry`, `rz`: pitch (X), yaw (Y), and roll (Z), in degrees.
+
+Every angle on the wire (`rx`, `ry`, `rz`, and `fov`) is expressed in degrees.
+
+Orthographic scale and perspective FOV are stored independently per slot, so
+one command may set both `scale` and `fov` regardless of the current or
+requested `type`, and changing `type` never reinterprets the other mode's
+projection value.
+
+Malformed or non-finite numeric values, fields without a value, invalid `id`,
+invalid `set`, and unknown `type` values cause the camera command to be
+ignored. A valid partial command still applies its other fields when `scale`
+or `fov` is numeric but outside its valid range; such an out-of-range value
+also does not block `set=1` activation — the slot activates with its previous
+projection value.
+
## Example Session
Register an embedded object path:
@@ -239,6 +281,18 @@ Rotate it later:
ESC _ ratty;g;u;id=7;ry=180 ESC \
```
+View the terminal in perspective with a 50-degree field of view:
+
+```text
+ESC _ ratty;g;c;id=0;set=1;type=Persp;fov=50;rx=10;ry=20 ESC \
+```
+
+Return to the flat terminal:
+
+```text
+ESC _ ratty;g;c;id=0;set=1;type=Flat ESC \
+```
+
Delete it:
```text
diff --git a/src/camera.rs b/src/camera.rs
new file mode 100644
index 0000000..a4fdb4a
--- /dev/null
+++ b/src/camera.rs
@@ -0,0 +1,870 @@
+//! Terminal camera presets, protocol updates, and activation systems.
+
+use bevy::prelude::*;
+
+use crate::scene::{MobiusEnterZoomFloor, MobiusTransition, TerminalPresentationMode};
+
+/// Number of addressable terminal camera presets.
+pub const TERMINAL_CAMERA_SLOT_COUNT: usize = 10;
+/// Smallest valid perspective field of view in radians (about 2.87 degrees).
+/// The RGP `fov` field arrives in degrees and is converted during parsing, so
+/// every value checked against this clamp is already radians.
+pub const MIN_PERSPECTIVE_FOV: f32 = 0.05;
+/// Largest valid perspective field of view in radians (about 177.13 degrees).
+pub const MAX_PERSPECTIVE_FOV: f32 = std::f32::consts::PI - 0.05;
+/// Smallest valid orthographic projection scale, shared by protocol updates
+/// and interactive wheel zoom.
+pub const MIN_ORTHOGRAPHIC_SCALE: f32 = 0.01;
+
+/// Ordered camera pipeline stages with actual resource dependencies.
+#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
+pub enum TerminalCameraSystemSet {
+ /// Apply parsed RGP partial updates.
+ ProtocolUpdates,
+ /// Apply keyboard mode changes and queue slot activations.
+ KeyboardInput,
+ /// Activate presets after their updates and keyboard requests.
+ Activation,
+ /// Apply mouse interaction to the active pose.
+ MouseInput,
+ /// Advance explicit Mobius transitions.
+ Transition,
+ /// Synchronize the resulting state to Bevy entities.
+ Synchronize,
+}
+
+/// Persistent pose for a terminal camera preset.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct TerminalCameraPose {
+ /// Rotation around the vertical axis, in radians.
+ pub yaw: f32,
+ /// Rotation around the horizontal axis, in radians.
+ pub pitch: f32,
+ /// Rotation around the view axis, in radians.
+ pub roll: f32,
+ /// Orthographic projection scale.
+ pub orthographic_scale: f32,
+ /// Perspective vertical field of view in radians.
+ pub perspective_fov: f32,
+ /// Camera translation relative to its default position.
+ pub translation: Vec3,
+}
+
+impl TerminalCameraPose {
+ /// Returns a finite, positive orthographic scale.
+ pub fn resolved_orthographic_scale(self) -> f32 {
+ if self.orthographic_scale.is_finite() {
+ self.orthographic_scale.max(MIN_ORTHOGRAPHIC_SCALE)
+ } else {
+ 1.0
+ }
+ }
+
+ /// Returns a finite perspective field of view away from zero and pi.
+ pub fn resolved_perspective_fov(self) -> f32 {
+ if self.perspective_fov.is_finite() {
+ self.perspective_fov
+ .clamp(MIN_PERSPECTIVE_FOV, MAX_PERSPECTIVE_FOV)
+ } else {
+ 1.0
+ }
+ }
+}
+
+impl Default for TerminalCameraPose {
+ fn default() -> Self {
+ Self {
+ yaw: 0.18,
+ pitch: 0.08,
+ roll: 0.0,
+ orthographic_scale: 1.0,
+ perspective_fov: 1.0,
+ translation: Vec3::ZERO,
+ }
+ }
+}
+
+/// Presentation mode and pose stored in one camera slot.
+#[derive(Clone, Copy, Debug, PartialEq, Default)]
+pub struct TerminalCameraPreset {
+ /// Presentation mode selected by the preset.
+ pub mode: TerminalPresentationMode,
+ /// Persistent camera pose selected by the preset.
+ pub pose: TerminalCameraPose,
+ /// Presentation state restored when leaving Mobius mode.
+ pub mobius_source: Option,
+}
+
+/// Per-slot presentation state saved before entering Mobius mode.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct TerminalMobiusSource {
+ /// Presentation mode restored after the exit transition.
+ pub mode: TerminalPresentationMode,
+ /// Camera pose restored after the exit transition.
+ pub pose: TerminalCameraPose,
+}
+
+/// The ten persistent camera presets and the active slot.
+#[derive(Resource, Clone, Debug)]
+pub struct TerminalCameraSlots {
+ presets: [TerminalCameraPreset; TERMINAL_CAMERA_SLOT_COUNT],
+ active_slot: usize,
+}
+
+impl TerminalCameraSlots {
+ /// Returns the active slot index.
+ pub fn active_slot(&self) -> usize {
+ self.active_slot
+ }
+
+ /// Returns the active preset.
+ pub fn active(&self) -> &TerminalCameraPreset {
+ &self.presets[self.active_slot]
+ }
+
+ /// Returns the active preset for a user interaction update.
+ pub fn active_mut(&mut self) -> &mut TerminalCameraPreset {
+ &mut self.presets[self.active_slot]
+ }
+
+ /// Returns a preset by slot index.
+ pub fn preset(&self, slot: usize) -> Option<&TerminalCameraPreset> {
+ self.presets.get(slot)
+ }
+
+ /// Activates a preset, returning whether the active slot changed.
+ pub fn activate(&mut self, slot: usize) -> bool {
+ if slot >= TERMINAL_CAMERA_SLOT_COUNT || self.active_slot == slot {
+ return false;
+ }
+ self.active_slot = slot;
+ true
+ }
+
+ fn updated_preset(&self, update: TerminalCameraUpdate) -> Option {
+ let current = self.presets.get(update.slot).copied()?;
+ let mut next = current;
+
+ if let Some(mode) = update.mode {
+ next.mode = mode;
+ }
+ apply_pose_update(&mut next.pose, update);
+
+ let mode_changed = next.mode != current.mode;
+ let pose_changed = next.pose != current.pose;
+ if next.mode == TerminalPresentationMode::Mobius3d {
+ if mode_changed {
+ next.mobius_source = Some(TerminalMobiusSource {
+ mode: current.mode,
+ pose: next.pose,
+ });
+ } else {
+ let source = current.mobius_source.unwrap_or(TerminalMobiusSource {
+ mode: TerminalPresentationMode::Plane3d,
+ pose: current.pose,
+ });
+ let mut updated_source = source;
+ apply_pose_update(&mut updated_source.pose, update);
+ if pose_changed || updated_source != source {
+ next.mobius_source = Some(updated_source);
+ }
+ }
+ } else {
+ next.mobius_source = None;
+ }
+
+ if next == current {
+ return None;
+ }
+ Some(next)
+ }
+
+ #[cfg(test)]
+ fn apply_update(&mut self, update: TerminalCameraUpdate) -> bool {
+ let Some(next) = self.updated_preset(update) else {
+ return false;
+ };
+ self.presets[update.slot] = next;
+ true
+ }
+}
+
+fn apply_pose_update(pose: &mut TerminalCameraPose, update: TerminalCameraUpdate) {
+ if let Some(value) = update.translation.x {
+ pose.translation.x = value;
+ }
+ if let Some(value) = update.translation.y {
+ pose.translation.y = value;
+ }
+ if let Some(value) = update.translation.z {
+ pose.translation.z = value;
+ }
+ if let Some(value) = update.rotation_degrees.x {
+ pose.pitch = value.to_radians();
+ }
+ if let Some(value) = update.rotation_degrees.y {
+ pose.yaw = value.to_radians();
+ }
+ if let Some(value) = update.rotation_degrees.z {
+ pose.roll = value.to_radians();
+ }
+ // Scale and FOV target their independently stored projection values
+ // regardless of the update's or slot's mode; out-of-range values leave
+ // the previous value in place without invalidating the rest.
+ if let Some(value) = update.scale
+ && value >= MIN_ORTHOGRAPHIC_SCALE
+ {
+ pose.orthographic_scale = value;
+ }
+ if let Some(value) = update.fov
+ && (MIN_PERSPECTIVE_FOV..=MAX_PERSPECTIVE_FOV).contains(&value)
+ {
+ pose.perspective_fov = value;
+ }
+}
+
+impl Default for TerminalCameraSlots {
+ fn default() -> Self {
+ Self {
+ presets: [TerminalCameraPreset::default(); TERMINAL_CAMERA_SLOT_COUNT],
+ active_slot: 0,
+ }
+ }
+}
+
+/// Transient mouse interaction state, kept outside persistent presets.
+#[derive(Resource, Default)]
+pub struct TerminalCameraInteraction {
+ /// Indicates an active rotation drag.
+ pub rotating: bool,
+ /// Indicates an active panning drag.
+ pub panning: bool,
+ /// Previous cursor position during rotation.
+ pub last_rotate_cursor: Option,
+ /// Previous cursor position during panning.
+ pub last_pan_cursor: Option,
+}
+
+impl TerminalCameraInteraction {
+ /// Clears transient drag state after a mode or slot transition.
+ pub fn reset(&mut self) {
+ self.rotating = false;
+ self.panning = false;
+ self.last_rotate_cursor = None;
+ self.last_pan_cursor = None;
+ }
+}
+
+/// Optional vector components in a partial camera update.
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub struct OptionalVec3 {
+ /// Optional X component.
+ pub x: Option,
+ /// Optional Y component.
+ pub y: Option,
+ /// Optional Z component.
+ pub z: Option,
+}
+
+impl From<[Option; 3]> for OptionalVec3 {
+ fn from(value: [Option; 3]) -> Self {
+ Self {
+ x: value[0],
+ y: value[1],
+ z: value[2],
+ }
+ }
+}
+
+/// Parsed partial camera update queued by the RGP input path.
+#[derive(Message, Clone, Copy, Debug, PartialEq)]
+pub struct TerminalCameraUpdate {
+ /// Slot to update.
+ pub slot: usize,
+ /// Whether to activate the slot after applying the update.
+ pub activate: bool,
+ /// Optional presentation mode.
+ pub mode: Option,
+ /// Optional orthographic scale; ignored below [`MIN_ORTHOGRAPHIC_SCALE`].
+ pub scale: Option,
+ /// Optional perspective FOV in radians — the RGP parser converts the
+ /// wire's degrees before constructing this update. Ignored outside
+ /// [`MIN_PERSPECTIVE_FOV`]`..=`[`MAX_PERSPECTIVE_FOV`].
+ pub fov: Option,
+ /// Optional translation components.
+ pub translation: OptionalVec3,
+ /// Optional rotation components in degrees, mapped as pitch, yaw, and roll.
+ pub rotation_degrees: OptionalVec3,
+}
+
+/// Request to activate one of the ten camera presets.
+#[derive(Message, Clone, Copy, Debug, PartialEq, Eq)]
+pub struct ActivateTerminalCameraPreset {
+ /// Slot to activate.
+ pub slot: usize,
+}
+
+/// Applies queued RGP partial updates before any requested activation.
+pub fn apply_terminal_camera_updates(
+ mut updates: MessageReader,
+ mut activations: MessageWriter,
+ mut slots: ResMut,
+ mut interaction: ResMut,
+ mut mobius_transition: ResMut,
+) {
+ for update in updates.read().copied() {
+ let previous = slots.preset(update.slot).copied();
+ let changed = if let Some(next) = slots.updated_preset(update) {
+ slots.presets[update.slot] = next;
+ true
+ } else {
+ false
+ };
+ if changed && let Some(previous) = previous {
+ let next = slots.presets[update.slot];
+ let mode_changed = next.mode != previous.mode;
+ if update.slot == slots.active_slot() || update.activate {
+ mobius_transition.stop();
+ if next.mode == TerminalPresentationMode::Mobius3d {
+ let source = next.mobius_source.unwrap_or(TerminalMobiusSource {
+ mode: TerminalPresentationMode::Plane3d,
+ pose: next.pose,
+ });
+ if mode_changed && update.slot == slots.active_slot() {
+ // A protocol switch into Mobius on the displayed slot
+ // animates like the keyboard toggle, but honors the
+ // requested scale exactly: begin_enter recomputes the
+ // zoom floor from its argument on every call, so no
+ // earlier keyboard floor can persist. The stop()
+ // above only makes this a fresh enter whose start
+ // values snap to the live pose — the documented
+ // protocol-cancel semantics. Pose-only updates while
+ // already in Mobius stay immediate.
+ mobius_transition.begin_enter(
+ update.slot,
+ &source,
+ &next.pose,
+ MobiusEnterZoomFloor::ProtocolExact,
+ );
+ } else {
+ mobius_transition.prepare_source(update.slot, source.mode, &source.pose);
+ }
+ }
+ }
+ if mode_changed && update.slot == slots.active_slot() {
+ interaction.reset();
+ }
+ }
+ if update.activate {
+ activations.write(ActivateTerminalCameraPreset { slot: update.slot });
+ }
+ }
+}
+
+/// Activates requested presets after their partial RGP updates have been applied.
+pub fn activate_terminal_camera_presets(
+ mut activations: MessageReader,
+ mut slots: ResMut,
+ mut interaction: ResMut,
+ mut mobius_transition: ResMut,
+) {
+ for activation in activations.read().copied() {
+ let was_mobius = slots.active().mode == TerminalPresentationMode::Mobius3d;
+ let strip_in_flight = was_mobius && mobius_transition.active;
+ if slots.activate(activation.slot) {
+ interaction.reset();
+ if slots.active().mode != TerminalPresentationMode::Mobius3d {
+ mobius_transition.stop();
+ continue;
+ }
+ let source = slots
+ .active()
+ .mobius_source
+ .unwrap_or(TerminalMobiusSource {
+ mode: TerminalPresentationMode::Plane3d,
+ pose: slots.active().pose,
+ });
+ if slots.active().mobius_source.is_none() {
+ slots.active_mut().mobius_source = Some(source);
+ }
+ if was_mobius && !strip_in_flight {
+ // A settled strip was displayed by the previous slot; keep it
+ // settled and just re-point the exit source.
+ mobius_transition.stop();
+ mobius_transition.prepare_source(activation.slot, source.mode, &source.pose);
+ } else if strip_in_flight {
+ // The strip animation is mid-flight; re-own its source for
+ // the new slot WITHOUT stopping so begin_enter resumes from
+ // the currently displayed morph and camera values instead of
+ // snapping the strip.
+ mobius_transition.prepare_source(activation.slot, source.mode, &source.pose);
+ mobius_transition.begin_enter(
+ activation.slot,
+ &source,
+ &slots.active().pose,
+ MobiusEnterZoomFloor::ProtocolExact,
+ );
+ } else {
+ // Activating a Mobius slot from a non-Mobius view animates
+ // the strip in like the keyboard toggle, but at the slot's
+ // exact stored scale (recomputed from the zoom_floor argument
+ // on every begin_enter) so activation never rewrites the
+ // preset at finish. The stop() only forces a fresh enter
+ // whose start values snap to the newly displayed pose.
+ mobius_transition.stop();
+ mobius_transition.begin_enter(
+ activation.slot,
+ &source,
+ &slots.active().pose,
+ MobiusEnterZoomFloor::ProtocolExact,
+ );
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::terminal::TerminalRedrawState;
+
+ fn update(slot: usize) -> TerminalCameraUpdate {
+ TerminalCameraUpdate {
+ slot,
+ activate: false,
+ mode: None,
+ scale: None,
+ fov: None,
+ translation: OptionalVec3::default(),
+ rotation_degrees: OptionalVec3::default(),
+ }
+ }
+
+ #[test]
+ fn partial_updates_preserve_omitted_pose_fields() {
+ let mut slots = TerminalCameraSlots::default();
+ let original = *slots.preset(3).expect("preset");
+ let mut partial = update(3);
+ partial.translation.x = Some(12.0);
+ partial.rotation_degrees.y = Some(90.0);
+
+ assert!(slots.apply_update(partial));
+ let preset = slots.preset(3).expect("preset");
+ assert_eq!(preset.mode, original.mode);
+ assert_eq!(preset.pose.translation, Vec3::new(12.0, 0.0, 0.0));
+ assert_eq!(preset.pose.yaw, std::f32::consts::FRAC_PI_2);
+ assert_eq!(preset.pose.pitch, original.pose.pitch);
+ assert_eq!(preset.pose.roll, original.pose.roll);
+ assert_eq!(
+ preset.pose.orthographic_scale,
+ original.pose.orthographic_scale
+ );
+ assert_eq!(preset.pose.perspective_fov, original.pose.perspective_fov);
+ }
+
+ #[test]
+ fn protocol_axes_map_to_pitch_yaw_and_roll_in_degrees() {
+ let mut slots = TerminalCameraSlots::default();
+ let mut partial = update(0);
+ partial.rotation_degrees = OptionalVec3 {
+ x: Some(10.0),
+ y: Some(20.0),
+ z: Some(30.0),
+ };
+
+ assert!(slots.apply_update(partial));
+ let pose = slots.active().pose;
+ assert_eq!(pose.pitch, 10.0_f32.to_radians());
+ assert_eq!(pose.yaw, 20.0_f32.to_radians());
+ assert_eq!(pose.roll, 30.0_f32.to_radians());
+ }
+
+ #[test]
+ fn invalid_projection_values_do_not_replace_the_previous_value() {
+ let mut slots = TerminalCameraSlots::default();
+ let mut partial = update(0);
+ partial.mode = Some(TerminalPresentationMode::Plane3d);
+ partial.scale = Some(MIN_ORTHOGRAPHIC_SCALE / 2.0);
+
+ assert!(slots.apply_update(partial));
+ assert_eq!(slots.active().mode, TerminalPresentationMode::Plane3d);
+ assert_eq!(slots.active().pose.orthographic_scale, 1.0);
+
+ let mut partial = update(0);
+ partial.mode = Some(TerminalPresentationMode::Perspective3d);
+ partial.fov = Some(std::f32::consts::PI);
+
+ assert!(slots.apply_update(partial));
+ assert_eq!(slots.active().mode, TerminalPresentationMode::Perspective3d);
+ assert_eq!(slots.active().pose.perspective_fov, 1.0);
+ }
+
+ #[test]
+ fn scale_and_fov_update_their_stored_values_in_any_mode() {
+ let mut slots = TerminalCameraSlots::default();
+ let mut partial = update(0);
+ partial.scale = Some(1.5);
+ partial.fov = Some(0.9);
+
+ assert!(slots.apply_update(partial));
+ assert_eq!(slots.active().mode, TerminalPresentationMode::Flat2d);
+ assert_eq!(slots.active().pose.orthographic_scale, 1.5);
+ assert_eq!(slots.active().pose.perspective_fov, 0.9);
+
+ let mut partial = update(0);
+ partial.mode = Some(TerminalPresentationMode::Perspective3d);
+ partial.scale = Some(2.0);
+
+ assert!(slots.apply_update(partial));
+ assert_eq!(slots.active().pose.orthographic_scale, 2.0);
+ assert_eq!(slots.active().pose.perspective_fov, 0.9);
+ }
+
+ #[test]
+ fn fov_applies_only_inside_the_radian_clamp() {
+ let mut slots = TerminalCameraSlots::default();
+ let mut partial = update(0);
+ partial.fov = Some(MIN_PERSPECTIVE_FOV);
+ assert!(slots.apply_update(partial));
+ assert_eq!(slots.active().pose.perspective_fov, MIN_PERSPECTIVE_FOV);
+
+ let mut partial = update(0);
+ partial.fov = Some(MAX_PERSPECTIVE_FOV);
+ assert!(slots.apply_update(partial));
+ assert_eq!(slots.active().pose.perspective_fov, MAX_PERSPECTIVE_FOV);
+
+ let mut partial = update(0);
+ partial.fov = Some(MIN_PERSPECTIVE_FOV - 0.001);
+ assert!(!slots.apply_update(partial));
+
+ let mut partial = update(0);
+ partial.fov = Some(MAX_PERSPECTIVE_FOV + 0.001);
+ assert!(!slots.apply_update(partial));
+ assert_eq!(slots.active().pose.perspective_fov, MAX_PERSPECTIVE_FOV);
+ }
+
+ #[test]
+ fn activation_rejects_out_of_range_slots() {
+ let mut slots = TerminalCameraSlots::default();
+ assert!(!slots.activate(TERMINAL_CAMERA_SLOT_COUNT));
+ assert_eq!(slots.active_slot(), 0);
+ }
+
+ #[test]
+ fn projections_are_always_finite_and_valid() {
+ let mut pose = TerminalCameraPose::default();
+ for value in [f32::NAN, f32::INFINITY, -1.0, 0.0, std::f32::consts::PI] {
+ pose.orthographic_scale = value;
+ pose.perspective_fov = value;
+ assert!(pose.resolved_orthographic_scale().is_finite());
+ assert!(pose.resolved_orthographic_scale() > 0.0);
+ assert!(pose.resolved_perspective_fov().is_finite());
+ assert!(pose.resolved_perspective_fov() > 0.0);
+ assert!(pose.resolved_perspective_fov() < std::f32::consts::PI);
+ }
+ }
+
+ #[test]
+ fn orthographic_zoom_does_not_change_perspective_fov() {
+ let mut slots = TerminalCameraSlots::default();
+ slots.active_mut().mode = TerminalPresentationMode::Plane3d;
+ slots.active_mut().pose.orthographic_scale = 4.0;
+ let expected_fov = slots.active().pose.perspective_fov;
+
+ slots.active_mut().mode = TerminalPresentationMode::Perspective3d;
+
+ assert_eq!(slots.active().pose.perspective_fov, expected_fov);
+ assert_eq!(slots.active().pose.resolved_perspective_fov(), 1.0);
+ }
+
+ #[test]
+ fn immediate_activation_happens_after_the_slot_update() {
+ let mut app = App::new();
+ app.init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .add_message::()
+ .add_message::()
+ .add_systems(
+ Update,
+ (
+ apply_terminal_camera_updates,
+ activate_terminal_camera_presets,
+ )
+ .chain(),
+ );
+ let mut command = update(4);
+ command.activate = true;
+ command.mode = Some(TerminalPresentationMode::Perspective3d);
+ command.translation.z = Some(50.0);
+ app.world_mut().write_message(command);
+
+ app.update();
+
+ let slots = app.world().resource::();
+ assert_eq!(slots.active_slot(), 4);
+ assert_eq!(slots.active().mode, TerminalPresentationMode::Perspective3d);
+ assert_eq!(slots.active().pose.translation.z, 50.0);
+ }
+
+ #[test]
+ fn mobius_exit_sources_are_persistent_per_slot() {
+ let mut app = App::new();
+ app.init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .add_message::()
+ .add_message::()
+ .add_systems(
+ Update,
+ (
+ apply_terminal_camera_updates,
+ activate_terminal_camera_presets,
+ )
+ .chain(),
+ );
+
+ let mut slot_zero = update(0);
+ slot_zero.mode = Some(TerminalPresentationMode::Mobius3d);
+ app.world_mut().write_message(slot_zero);
+ app.update();
+
+ let mut slot_one = update(1);
+ slot_one.mode = Some(TerminalPresentationMode::Perspective3d);
+ app.world_mut().write_message(slot_one);
+ app.update();
+
+ let mut slot_one = update(1);
+ slot_one.activate = true;
+ slot_one.mode = Some(TerminalPresentationMode::Mobius3d);
+ app.world_mut().write_message(slot_one);
+ app.update();
+
+ app.world_mut()
+ .write_message(ActivateTerminalCameraPreset { slot: 0 });
+ app.update();
+
+ let slots = app.world().resource::();
+ assert_eq!(slots.active_slot(), 0);
+ assert_eq!(
+ slots.active().mobius_source.expect("slot source").mode,
+ TerminalPresentationMode::Flat2d
+ );
+ let transition = app.world().resource::();
+ assert!(transition.source_is_for(0));
+ assert_eq!(transition.source_mode, TerminalPresentationMode::Flat2d);
+ }
+
+ #[test]
+ fn activating_a_mobius_slot_starts_the_enter_transition() {
+ use crate::scene::MobiusTransitionDirection;
+
+ let mut app = App::new();
+ app.init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .add_message::()
+ .add_message::()
+ .add_systems(
+ Update,
+ (
+ apply_terminal_camera_updates,
+ activate_terminal_camera_presets,
+ )
+ .chain(),
+ );
+ let mut command = update(3);
+ command.activate = true;
+ command.mode = Some(TerminalPresentationMode::Mobius3d);
+ app.world_mut().write_message(command);
+
+ app.update();
+
+ let transition = app.world().resource::();
+ assert!(transition.active);
+ assert!(matches!(
+ transition.direction,
+ MobiusTransitionDirection::Entering
+ ));
+ assert_eq!(transition.morph_progress(), 0.0);
+ assert!(transition.source_is_for(3));
+
+ // Switching between two Mobius slots keeps the settled strip instead
+ // of collapsing it flat and re-morphing.
+ app.world_mut().resource_mut::().stop();
+ let mut other = update(5);
+ other.activate = true;
+ other.mode = Some(TerminalPresentationMode::Mobius3d);
+ app.world_mut().write_message(other);
+ app.update();
+
+ let transition = app.world().resource::();
+ assert!(!transition.active);
+ assert!(transition.source_is_for(5));
+
+ // A mid-flight strip animation is handed to the next Mobius slot
+ // continuously instead of snapping to the settled strip.
+ app.world_mut()
+ .write_message(ActivateTerminalCameraPreset { slot: 1 });
+ app.update();
+ app.world_mut()
+ .write_message(ActivateTerminalCameraPreset { slot: 3 });
+ app.update();
+ {
+ let mut transition = app.world_mut().resource_mut::();
+ assert!(transition.active);
+ transition.elapsed_secs =
+ MobiusTransition::ZOOM_OUT_SECS + MobiusTransition::MORPH_SECS * 0.4;
+ }
+ let morph_before = app.world().resource::().morph_progress();
+ assert!((morph_before - 0.4).abs() < 1e-6);
+ app.world_mut()
+ .write_message(ActivateTerminalCameraPreset { slot: 5 });
+ app.update();
+
+ let transition = app.world().resource::();
+ assert!(transition.active);
+ assert!(transition.source_is_for(5));
+ assert!((transition.morph_progress() - morph_before).abs() < 1e-6);
+ }
+
+ #[test]
+ fn protocol_updates_cancel_an_active_mobius_transition() {
+ let mut app = App::new();
+ app.init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .add_message::()
+ .add_message::()
+ .add_systems(Update, apply_terminal_camera_updates);
+
+ let mut enter_mobius = update(0);
+ enter_mobius.mode = Some(TerminalPresentationMode::Mobius3d);
+ app.world_mut().write_message(enter_mobius);
+ app.update();
+
+ let preset = *app.world().resource::().active();
+ let source = preset.mobius_source.expect("Mobius source");
+ app.world_mut()
+ .resource_mut::()
+ .begin_enter(
+ 0,
+ &source,
+ &preset.pose,
+ MobiusEnterZoomFloor::KeyboardTarget,
+ );
+
+ let mut camera_update = update(0);
+ camera_update.scale = Some(2.0);
+ app.world_mut().write_message(camera_update);
+ app.update();
+
+ let slots = app.world().resource::();
+ assert_eq!(slots.active().pose.orthographic_scale, 2.0);
+ assert_eq!(
+ slots
+ .active()
+ .mobius_source
+ .expect("Mobius source")
+ .pose
+ .orthographic_scale,
+ 2.0
+ );
+ assert!(!app.world().resource::().active);
+ }
+
+ #[test]
+ fn partial_mobius_updates_preserve_omitted_source_fields() {
+ let mut slots = TerminalCameraSlots::default();
+ let mut source = update(0);
+ source.mode = Some(TerminalPresentationMode::Plane3d);
+ source.scale = Some(0.05);
+ assert!(slots.apply_update(source));
+
+ let mut enter_mobius = update(0);
+ enter_mobius.mode = Some(TerminalPresentationMode::Mobius3d);
+ assert!(slots.apply_update(enter_mobius));
+ slots.active_mut().pose.orthographic_scale = 1.0;
+
+ let mut rotate = update(0);
+ rotate.rotation_degrees.y = Some(45.0);
+ assert!(slots.apply_update(rotate));
+
+ let source = slots.active().mobius_source.expect("Mobius source");
+ assert_eq!(source.pose.orthographic_scale, 0.05);
+ assert_eq!(source.pose.yaw, 45.0_f32.to_radians());
+ }
+
+ #[test]
+ fn mobius_updates_compare_against_the_saved_source_pose() {
+ let mut slots = TerminalCameraSlots::default();
+ let mut source = update(0);
+ source.mode = Some(TerminalPresentationMode::Plane3d);
+ source.scale = Some(0.05);
+ assert!(slots.apply_update(source));
+
+ let mut enter_mobius = update(0);
+ enter_mobius.mode = Some(TerminalPresentationMode::Mobius3d);
+ assert!(slots.apply_update(enter_mobius));
+ slots.active_mut().pose.orthographic_scale = 1.0;
+
+ let mut match_live_pose = update(0);
+ match_live_pose.scale = Some(1.0);
+ assert!(slots.apply_update(match_live_pose));
+
+ let source = slots.active().mobius_source.expect("Mobius source");
+ assert_eq!(source.pose.orthographic_scale, 1.0);
+ }
+
+ #[test]
+ fn application_updates_remain_after_user_interaction_and_slot_switches() {
+ let mut slots = TerminalCameraSlots::default();
+ let mut command = update(2);
+ command.mode = Some(TerminalPresentationMode::Plane3d);
+ command.translation.x = Some(25.0);
+ slots.apply_update(command);
+ slots.activate(2);
+
+ slots.active_mut().pose.yaw += 0.5;
+ let expected = *slots.active();
+ slots.activate(1);
+ slots.activate(2);
+
+ assert_eq!(*slots.active(), expected);
+ }
+
+ #[test]
+ fn camera_protocol_updates_do_not_request_terminal_texture_redraws() {
+ let mut app = App::new();
+ app.init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .add_message::()
+ .add_message::()
+ .add_systems(
+ Update,
+ (
+ apply_terminal_camera_updates,
+ activate_terminal_camera_presets,
+ )
+ .chain(),
+ );
+ assert!(app.world_mut().resource_mut::().take());
+ {
+ let mut interaction = app.world_mut().resource_mut::();
+ interaction.rotating = true;
+ interaction.last_rotate_cursor = Some(Vec2::new(10.0, 20.0));
+ }
+ let mut command = update(0);
+ command.mode = Some(TerminalPresentationMode::Perspective3d);
+ app.world_mut().write_message(command);
+
+ app.update();
+
+ assert!(!app.world_mut().resource_mut::().take());
+ let interaction = app.world().resource::();
+ assert!(!interaction.rotating);
+ assert_eq!(interaction.last_rotate_cursor, None);
+ }
+}
diff --git a/src/config.rs b/src/config.rs
index d50350d..2ec5c0e 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -229,12 +229,46 @@ pub enum BindingAction {
/// Disables a binding.
#[serde(rename = "none")]
None,
- /// Toggles between the flat and warped terminal views.
- #[serde(rename = "Toggle3DMode")]
- Toggle3DMode,
+ /// Toggles between the flat and orthographic terminal views.
+ #[serde(rename = "ToggleOrtho3DMode")]
+ #[serde(alias = "Toggle3DMode")]
+ ToggleOrtho3DMode,
+ /// Toggles the perspective terminal view.
+ #[serde(rename = "TogglePersp3DMode")]
+ TogglePersp3DMode,
/// Toggles the Mobius-strip terminal view.
#[serde(rename = "ToggleMobiusMode")]
ToggleMobiusMode,
+ /// Activates camera preset 0.
+ #[serde(rename = "ActivateCameraSlot0")]
+ ActivateCameraSlot0,
+ /// Activates camera preset 1.
+ #[serde(rename = "ActivateCameraSlot1")]
+ ActivateCameraSlot1,
+ /// Activates camera preset 2.
+ #[serde(rename = "ActivateCameraSlot2")]
+ ActivateCameraSlot2,
+ /// Activates camera preset 3.
+ #[serde(rename = "ActivateCameraSlot3")]
+ ActivateCameraSlot3,
+ /// Activates camera preset 4.
+ #[serde(rename = "ActivateCameraSlot4")]
+ ActivateCameraSlot4,
+ /// Activates camera preset 5.
+ #[serde(rename = "ActivateCameraSlot5")]
+ ActivateCameraSlot5,
+ /// Activates camera preset 6.
+ #[serde(rename = "ActivateCameraSlot6")]
+ ActivateCameraSlot6,
+ /// Activates camera preset 7.
+ #[serde(rename = "ActivateCameraSlot7")]
+ ActivateCameraSlot7,
+ /// Activates camera preset 8.
+ #[serde(rename = "ActivateCameraSlot8")]
+ ActivateCameraSlot8,
+ /// Activates camera preset 9.
+ #[serde(rename = "ActivateCameraSlot9")]
+ ActivateCameraSlot9,
/// Scrolls one page up through scrollback.
#[serde(rename = "ScrollPageUp")]
ScrollPageUp,
@@ -270,6 +304,25 @@ pub enum BindingAction {
ResetFontSize,
}
+impl BindingAction {
+ /// Returns the camera slot selected by this action, if any.
+ pub const fn camera_slot(self) -> Option {
+ match self {
+ Self::ActivateCameraSlot0 => Some(0),
+ Self::ActivateCameraSlot1 => Some(1),
+ Self::ActivateCameraSlot2 => Some(2),
+ Self::ActivateCameraSlot3 => Some(3),
+ Self::ActivateCameraSlot4 => Some(4),
+ Self::ActivateCameraSlot5 => Some(5),
+ Self::ActivateCameraSlot6 => Some(6),
+ Self::ActivateCameraSlot7 => Some(7),
+ Self::ActivateCameraSlot8 => Some(8),
+ Self::ActivateCameraSlot9 => Some(9),
+ _ => None,
+ }
+ }
+}
+
/// Font configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
@@ -518,3 +571,47 @@ fn parse_hex_color(value: &str) -> anyhow::Result<[u8; 3]> {
.with_context(|| format!("invalid blue component in {value}"))?;
Ok([r, g, b])
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn accepts_legacy_toggle_3d_mode_binding_action() {
+ let binding: KeyBindingConfig = toml::from_str(
+ r#"
+key = "Enter"
+with = "Control | alt"
+action = "Toggle3DMode"
+"#,
+ )
+ .expect("legacy Toggle3DMode action should deserialize");
+
+ assert_eq!(binding.action, BindingAction::ToggleOrtho3DMode);
+ }
+
+ #[test]
+ fn parses_every_camera_slot_binding_action() {
+ for slot in 0..10 {
+ let source = format!(
+ "key = \"Digit{slot}\"\nwith = \"Control | alt | shift\"\naction = \"ActivateCameraSlot{slot}\""
+ );
+ let binding: KeyBindingConfig = toml::from_str(&source).expect("camera slot binding");
+ assert_eq!(binding.action.camera_slot(), Some(slot));
+ }
+ }
+
+ #[test]
+ fn distributed_config_binds_every_camera_slot() {
+ let config: AppConfig =
+ toml::from_str(include_str!("../config/ratty.toml")).expect("distributed config");
+ let mut slots = config
+ .bindings
+ .keys
+ .iter()
+ .filter_map(|binding| binding.action.camera_slot())
+ .collect::>();
+ slots.sort_unstable();
+ assert_eq!(slots, (0..10).collect::>());
+ }
+}
diff --git a/src/inline.rs b/src/inline.rs
index e1af450..1ab11cc 100644
--- a/src/inline.rs
+++ b/src/inline.rs
@@ -7,6 +7,7 @@ use std::path::Path;
use bevy::prelude::*;
use vt100::Callbacks;
+use crate::camera::{OptionalVec3, TerminalCameraUpdate};
use crate::kitty::{KittyOperation, KittyParserState, refresh_kitty_placeholder_anchors};
use crate::model::{
ObjectLoadOptions, load_object_source_from_bytes_with_options, load_object_source_with_options,
@@ -15,6 +16,7 @@ use crate::rgp::{
RgpOperation, RgpPlacementStyle, RgpPlacementUpdate, RgpRegisterSource,
consume_sequence as consume_rgp_sequence, support_reply,
};
+
const APC_START: &[u8] = b"\x1b_";
const ST: &[u8] = b"\x1b\\";
const C1_ST: u8 = 0x9c;
@@ -83,6 +85,8 @@ impl TerminalInlineObjects {
&mut self,
chunk: &[u8],
parser: &mut vt100::Parser,
+ camera_updates: &mut Vec,
+ terminal_output: &mut bool,
) -> Vec> {
self.pending_bytes.extend_from_slice(chunk);
let mut replies = Vec::new();
@@ -96,6 +100,7 @@ impl TerminalInlineObjects {
let pending_len = self.pending_bytes.len();
let keep_from = pending_apc_prefix_start(&self.pending_bytes, cursor);
if cursor < keep_from {
+ *terminal_output = true;
parser.process(&normalize_hvp_sequences(
&self.pending_bytes[cursor..keep_from],
));
@@ -109,6 +114,7 @@ impl TerminalInlineObjects {
};
let start = cursor + start_offset;
if cursor < start {
+ *terminal_output = true;
parser.process(&normalize_hvp_sequences(&self.pending_bytes[cursor..start]));
}
@@ -118,12 +124,16 @@ impl TerminalInlineObjects {
return replies;
};
let sequence = self.pending_bytes[start..end].to_vec();
- let (handled, reply) =
- self.handle_apc_sequence(&sequence, parser.screen().cursor_position());
+ let (handled, reply) = self.handle_apc_sequence(
+ &sequence,
+ parser.screen().cursor_position(),
+ camera_updates,
+ );
if let Some(reply) = reply {
replies.push(reply);
}
if !handled {
+ *terminal_output = true;
parser.process(&sequence);
}
cursor = end;
@@ -209,8 +219,9 @@ impl TerminalInlineObjects {
&mut self,
sequence: &[u8],
cursor_position: (u16, u16),
+ camera_updates: &mut Vec,
) -> (bool, Option>) {
- if let Some(reply) = self.handle_rgp_sequence(sequence) {
+ if let Some(reply) = self.handle_rgp_sequence(sequence, camera_updates) {
return (true, reply);
}
@@ -278,10 +289,30 @@ impl TerminalInlineObjects {
}
}
- fn handle_rgp_sequence(&mut self, sequence: &[u8]) -> Option>> {
+ fn handle_rgp_sequence(
+ &mut self,
+ sequence: &[u8],
+ camera_updates: &mut Vec,
+ ) -> Option>> {
let operation = consume_rgp_sequence(sequence)?;
Some(match operation {
RgpOperation::SupportQuery => Some(support_reply()),
+ RgpOperation::Camera {
+ camera_slot,
+ switch_immediately,
+ settings,
+ } => {
+ camera_updates.push(TerminalCameraUpdate {
+ slot: camera_slot as usize,
+ activate: switch_immediately,
+ mode: settings.camera_type,
+ scale: settings.scale,
+ fov: settings.fov,
+ translation: OptionalVec3::from(settings.offset),
+ rotation_degrees: OptionalVec3::from(settings.rotation),
+ });
+ None
+ }
RgpOperation::Register {
object_id,
format,
diff --git a/src/keyboard.rs b/src/keyboard.rs
index 4f9dca0..7acc4af 100644
--- a/src/keyboard.rs
+++ b/src/keyboard.rs
@@ -9,13 +9,16 @@ use bevy::window::{PrimaryWindow, Window};
use arboard::Clipboard;
+use crate::camera::{
+ ActivateTerminalCameraPreset, TerminalCameraInteraction, TerminalCameraSlots,
+ TerminalMobiusSource,
+};
use crate::config::{AppConfig, BindingAction, FontConfig, KeyBindingConfig};
use crate::mouse::{TerminalSelection, encode_mouse_wheel};
use crate::runtime::TerminalRuntime;
use crate::scene::{
- MobiusTransition, TerminalPlaneBackLayoutQuery, TerminalPlaneLayoutQuery, TerminalPlaneView,
- TerminalPlaneWarp, TerminalPresentation, TerminalPresentationMode, TerminalViewport,
- sync_terminal_layout,
+ MobiusEnterZoomFloor, MobiusTransition, TerminalPlaneBackLayoutQuery, TerminalPlaneLayoutQuery,
+ TerminalPlaneWarp, TerminalPresentationMode, TerminalViewport, sync_terminal_layout,
};
use crate::terminal::{TerminalRedrawState, TerminalSurface, render_scale_for_window};
@@ -59,144 +62,7 @@ pub struct TerminalKeyBindings {
impl FromWorld for TerminalKeyBindings {
fn from_world(world: &mut World) -> Self {
let app_config = world.resource::();
- let mut bindings = vec![
- KeyBinding::new(
- KeyCode::Enter,
- BindingModifiers {
- control: true,
- alt: true,
- ..default()
- },
- BindingAction::Toggle3DMode,
- ),
- KeyBinding::new(
- KeyCode::KeyM,
- BindingModifiers {
- control: true,
- alt: true,
- ..default()
- },
- BindingAction::ToggleMobiusMode,
- ),
- KeyBinding::new(
- KeyCode::PageUp,
- BindingModifiers {
- alt: true,
- ..default()
- },
- BindingAction::ScrollPageUp,
- ),
- KeyBinding::new(
- KeyCode::PageDown,
- BindingModifiers {
- alt: true,
- ..default()
- },
- BindingAction::ScrollPageDown,
- ),
- KeyBinding::new(
- KeyCode::ArrowUp,
- BindingModifiers {
- alt: true,
- ..default()
- },
- BindingAction::ScrollUp,
- ),
- KeyBinding::new(
- KeyCode::ArrowDown,
- BindingModifiers {
- alt: true,
- ..default()
- },
- BindingAction::ScrollDown,
- ),
- KeyBinding::new(
- KeyCode::ArrowUp,
- BindingModifiers {
- control: true,
- alt: true,
- ..default()
- },
- BindingAction::IncreaseWarp,
- ),
- KeyBinding::new(
- KeyCode::ArrowDown,
- BindingModifiers {
- control: true,
- alt: true,
- ..default()
- },
- BindingAction::DecreaseWarp,
- ),
- KeyBinding::new(
- KeyCode::KeyC,
- BindingModifiers {
- control: true,
- alt: true,
- ..default()
- },
- BindingAction::Copy,
- ),
- KeyBinding::new(
- KeyCode::KeyV,
- BindingModifiers {
- control: true,
- alt: true,
- ..default()
- },
- BindingAction::Paste,
- ),
- KeyBinding::new(
- KeyCode::Equal,
- BindingModifiers {
- control: true,
- ..default()
- },
- BindingAction::IncreaseFontSize,
- ),
- KeyBinding::new(
- KeyCode::NumpadAdd,
- BindingModifiers {
- control: true,
- ..default()
- },
- BindingAction::IncreaseFontSize,
- ),
- KeyBinding::new(
- KeyCode::Minus,
- BindingModifiers {
- control: true,
- ..default()
- },
- BindingAction::DecreaseFontSize,
- ),
- KeyBinding::new(
- KeyCode::NumpadSubtract,
- BindingModifiers {
- control: true,
- ..default()
- },
- BindingAction::DecreaseFontSize,
- ),
- KeyBinding::new(
- KeyCode::Digit0,
- BindingModifiers {
- control: true,
- alt: true,
- ..default()
- },
- BindingAction::ResetFontSize,
- ),
- KeyBinding::new(
- KeyCode::Numpad0,
- BindingModifiers {
- control: true,
- alt: true,
- ..default()
- },
- BindingAction::ResetFontSize,
- ),
- ];
+ let mut bindings = default_bindings();
for binding in &app_config.bindings.keys {
let Some(binding) = KeyBinding::from_config(binding) else {
@@ -223,6 +89,201 @@ impl FromWorld for TerminalKeyBindings {
}
}
+fn default_bindings() -> Vec {
+ let mut bindings = vec![
+ KeyBinding::new(
+ KeyCode::Enter,
+ BindingModifiers {
+ control: true,
+ alt: true,
+ ..default()
+ },
+ BindingAction::ToggleOrtho3DMode,
+ ),
+ KeyBinding::new(
+ KeyCode::KeyP,
+ BindingModifiers {
+ control: true,
+ alt: true,
+ ..default()
+ },
+ BindingAction::TogglePersp3DMode,
+ ),
+ KeyBinding::new(
+ KeyCode::KeyM,
+ BindingModifiers {
+ control: true,
+ alt: true,
+ ..default()
+ },
+ BindingAction::ToggleMobiusMode,
+ ),
+ KeyBinding::new(
+ KeyCode::PageUp,
+ BindingModifiers {
+ alt: true,
+ ..default()
+ },
+ BindingAction::ScrollPageUp,
+ ),
+ KeyBinding::new(
+ KeyCode::PageDown,
+ BindingModifiers {
+ alt: true,
+ ..default()
+ },
+ BindingAction::ScrollPageDown,
+ ),
+ KeyBinding::new(
+ KeyCode::ArrowUp,
+ BindingModifiers {
+ alt: true,
+ ..default()
+ },
+ BindingAction::ScrollUp,
+ ),
+ KeyBinding::new(
+ KeyCode::ArrowDown,
+ BindingModifiers {
+ alt: true,
+ ..default()
+ },
+ BindingAction::ScrollDown,
+ ),
+ KeyBinding::new(
+ KeyCode::ArrowUp,
+ BindingModifiers {
+ control: true,
+ alt: true,
+ ..default()
+ },
+ BindingAction::IncreaseWarp,
+ ),
+ KeyBinding::new(
+ KeyCode::ArrowDown,
+ BindingModifiers {
+ control: true,
+ alt: true,
+ ..default()
+ },
+ BindingAction::DecreaseWarp,
+ ),
+ KeyBinding::new(
+ KeyCode::KeyC,
+ BindingModifiers {
+ control: true,
+ alt: true,
+ ..default()
+ },
+ BindingAction::Copy,
+ ),
+ KeyBinding::new(
+ KeyCode::KeyV,
+ BindingModifiers {
+ control: true,
+ alt: true,
+ ..default()
+ },
+ BindingAction::Paste,
+ ),
+ KeyBinding::new(
+ KeyCode::Equal,
+ BindingModifiers {
+ control: true,
+ ..default()
+ },
+ BindingAction::IncreaseFontSize,
+ ),
+ KeyBinding::new(
+ KeyCode::NumpadAdd,
+ BindingModifiers {
+ control: true,
+ ..default()
+ },
+ BindingAction::IncreaseFontSize,
+ ),
+ KeyBinding::new(
+ KeyCode::Minus,
+ BindingModifiers {
+ control: true,
+ ..default()
+ },
+ BindingAction::DecreaseFontSize,
+ ),
+ KeyBinding::new(
+ KeyCode::NumpadSubtract,
+ BindingModifiers {
+ control: true,
+ ..default()
+ },
+ BindingAction::DecreaseFontSize,
+ ),
+ KeyBinding::new(
+ KeyCode::Digit0,
+ BindingModifiers {
+ control: true,
+ alt: true,
+ ..default()
+ },
+ BindingAction::ResetFontSize,
+ ),
+ KeyBinding::new(
+ KeyCode::Numpad0,
+ BindingModifiers {
+ control: true,
+ alt: true,
+ ..default()
+ },
+ BindingAction::ResetFontSize,
+ ),
+ ];
+ let slot_keys = [
+ KeyCode::Digit0,
+ KeyCode::Digit1,
+ KeyCode::Digit2,
+ KeyCode::Digit3,
+ KeyCode::Digit4,
+ KeyCode::Digit5,
+ KeyCode::Digit6,
+ KeyCode::Digit7,
+ KeyCode::Digit8,
+ KeyCode::Digit9,
+ ];
+ let slot_actions = [
+ BindingAction::ActivateCameraSlot0,
+ BindingAction::ActivateCameraSlot1,
+ BindingAction::ActivateCameraSlot2,
+ BindingAction::ActivateCameraSlot3,
+ BindingAction::ActivateCameraSlot4,
+ BindingAction::ActivateCameraSlot5,
+ BindingAction::ActivateCameraSlot6,
+ BindingAction::ActivateCameraSlot7,
+ BindingAction::ActivateCameraSlot8,
+ BindingAction::ActivateCameraSlot9,
+ ];
+ // No numpad variants: with Num Lock on Windows/X11, Shift+numpad delivers
+ // navigation logical keys, which would misroute these chords to the
+ // warp/scroll bindings instead of slot activation.
+ bindings.extend(
+ slot_keys
+ .into_iter()
+ .zip(slot_actions)
+ .map(|(key, action)| {
+ KeyBinding::new(
+ key,
+ BindingModifiers {
+ control: true,
+ alt: true,
+ shift: true,
+ ..default()
+ },
+ action,
+ )
+ }),
+ );
+ bindings
+}
+
impl TerminalKeyBindings {
fn action_for(&self, key_code: KeyCode, modifiers: BindingModifiers) -> Option {
self.bindings
@@ -312,8 +373,9 @@ pub struct KeyboardSystemParams<'w, 's> {
keys: Res<'w, ButtonInput>,
selection: ResMut<'w, TerminalSelection>,
plane_warp: ResMut<'w, TerminalPlaneWarp>,
- plane_view: ResMut<'w, TerminalPlaneView>,
- presentation: ResMut<'w, TerminalPresentation>,
+ camera_slots: ResMut<'w, TerminalCameraSlots>,
+ camera_interaction: ResMut<'w, TerminalCameraInteraction>,
+ camera_activations: MessageWriter<'w, ActivateTerminalCameraPreset>,
mobius_transition: ResMut<'w, MobiusTransition>,
clipboard: NonSendMut<'w, TerminalClipboard>,
runtime: ResMut<'w, TerminalRuntime>,
@@ -357,34 +419,49 @@ pub fn handle_keyboard_input(
continue;
}
+ if let Some(slot) = action.camera_slot() {
+ params
+ .camera_activations
+ .write(ActivateTerminalCameraPreset { slot });
+ params.selection.clear();
+ continue;
+ }
+
match action {
BindingAction::None => {}
- BindingAction::Toggle3DMode => {
- params.presentation.toggle_plane_mode();
+ BindingAction::ToggleOrtho3DMode => {
+ let preset = params.camera_slots.active_mut();
+ preset.mode = if preset.mode == TerminalPresentationMode::Plane3d {
+ TerminalPresentationMode::Flat2d
+ } else {
+ TerminalPresentationMode::Plane3d
+ };
+ preset.mobius_source = None;
+ params.camera_interaction.reset();
params.mobius_transition.stop();
params.selection.clear();
- params.redraw.request();
continue;
}
- BindingAction::ToggleMobiusMode => {
- if params.presentation.mode == TerminalPresentationMode::Mobius3d {
- let current_zoom = if params.mobius_transition.active {
- params.mobius_transition.current_zoom()
- } else {
- params.plane_view.zoom
- };
- params
- .mobius_transition
- .begin_exit(¶ms.plane_view, current_zoom);
+ BindingAction::TogglePersp3DMode => {
+ let preset = params.camera_slots.active_mut();
+ preset.mode = if preset.mode == TerminalPresentationMode::Perspective3d {
+ TerminalPresentationMode::Flat2d
} else {
- let previous_mode = params.presentation.mode;
- params.presentation.toggle_mobius_mode();
- params
- .mobius_transition
- .begin_enter(previous_mode, ¶ms.plane_view);
- }
+ TerminalPresentationMode::Perspective3d
+ };
+ preset.mobius_source = None;
+ params.camera_interaction.reset();
+ params.mobius_transition.stop();
+ params.selection.clear();
+ continue;
+ }
+ BindingAction::ToggleMobiusMode => {
+ toggle_mobius_presentation(
+ &mut params.camera_slots,
+ &mut params.camera_interaction,
+ &mut params.mobius_transition,
+ );
params.selection.clear();
- params.redraw.request();
continue;
}
BindingAction::ScrollPageUp
@@ -405,7 +482,7 @@ pub fn handle_keyboard_input(
};
let mouse_mode = params.runtime.parser.screen().mouse_protocol_mode();
- if params.presentation.mode == TerminalPresentationMode::Flat2d
+ if params.camera_slots.active().mode == TerminalPresentationMode::Flat2d
&& mouse_mode != vt100::MouseProtocolMode::None
{
let encoding = params.runtime.parser.screen().mouse_protocol_encoding();
@@ -505,6 +582,16 @@ pub fn handle_keyboard_input(
}
continue;
}
+ BindingAction::ActivateCameraSlot0
+ | BindingAction::ActivateCameraSlot1
+ | BindingAction::ActivateCameraSlot2
+ | BindingAction::ActivateCameraSlot3
+ | BindingAction::ActivateCameraSlot4
+ | BindingAction::ActivateCameraSlot5
+ | BindingAction::ActivateCameraSlot6
+ | BindingAction::ActivateCameraSlot7
+ | BindingAction::ActivateCameraSlot8
+ | BindingAction::ActivateCameraSlot9 => unreachable!("handled above"),
}
}
@@ -531,6 +618,57 @@ pub fn handle_keyboard_input(
}
}
+fn toggle_mobius_presentation(
+ camera_slots: &mut TerminalCameraSlots,
+ interaction: &mut TerminalCameraInteraction,
+ mobius_transition: &mut MobiusTransition,
+) {
+ let slot = camera_slots.active_slot();
+ let preset = *camera_slots.active();
+ if preset.mode == TerminalPresentationMode::Mobius3d {
+ let source = preset.mobius_source.unwrap_or(TerminalMobiusSource {
+ mode: TerminalPresentationMode::Plane3d,
+ pose: preset.pose,
+ });
+ if mobius_transition.active
+ && mobius_transition.direction == crate::scene::MobiusTransitionDirection::Exiting
+ {
+ // Toggling during an exit turns the animation back around; the
+ // preset is still Mobius, so restarting the exit here would lock
+ // out re-entry until the exit finished.
+ mobius_transition.begin_enter(
+ slot,
+ &source,
+ &preset.pose,
+ MobiusEnterZoomFloor::KeyboardTarget,
+ );
+ } else {
+ mobius_transition.prepare_source(slot, source.mode, &source.pose);
+ let current_zoom = if mobius_transition.active {
+ mobius_transition.current_zoom()
+ } else {
+ preset.pose.orthographic_scale
+ };
+ mobius_transition.begin_exit(slot, &preset.pose, current_zoom);
+ }
+ } else {
+ let source = TerminalMobiusSource {
+ mode: preset.mode,
+ pose: preset.pose,
+ };
+ mobius_transition.begin_enter(
+ slot,
+ &source,
+ &preset.pose,
+ MobiusEnterZoomFloor::KeyboardTarget,
+ );
+ let preset = camera_slots.active_mut();
+ preset.mobius_source = Some(source);
+ preset.mode = TerminalPresentationMode::Mobius3d;
+ }
+ interaction.reset();
+}
+
fn is_scroll_action(action: BindingAction) -> bool {
matches!(
action,
@@ -976,42 +1114,42 @@ fn is_modifier_key(key: KeyCode) -> bool {
fn parse_key_code(key: &str) -> Option {
match key.trim().to_ascii_lowercase().as_str() {
- "a" => Some(KeyCode::KeyA),
- "b" => Some(KeyCode::KeyB),
- "c" => Some(KeyCode::KeyC),
- "d" => Some(KeyCode::KeyD),
- "e" => Some(KeyCode::KeyE),
- "f" => Some(KeyCode::KeyF),
- "g" => Some(KeyCode::KeyG),
- "h" => Some(KeyCode::KeyH),
- "i" => Some(KeyCode::KeyI),
- "j" => Some(KeyCode::KeyJ),
- "k" => Some(KeyCode::KeyK),
- "l" => Some(KeyCode::KeyL),
- "m" => Some(KeyCode::KeyM),
- "n" => Some(KeyCode::KeyN),
- "o" => Some(KeyCode::KeyO),
- "p" => Some(KeyCode::KeyP),
- "q" => Some(KeyCode::KeyQ),
- "r" => Some(KeyCode::KeyR),
- "s" => Some(KeyCode::KeyS),
- "t" => Some(KeyCode::KeyT),
- "u" => Some(KeyCode::KeyU),
- "v" => Some(KeyCode::KeyV),
- "w" => Some(KeyCode::KeyW),
- "x" => Some(KeyCode::KeyX),
- "y" => Some(KeyCode::KeyY),
- "z" => Some(KeyCode::KeyZ),
- "0" => Some(KeyCode::Digit0),
- "1" => Some(KeyCode::Digit1),
- "2" => Some(KeyCode::Digit2),
- "3" => Some(KeyCode::Digit3),
- "4" => Some(KeyCode::Digit4),
- "5" => Some(KeyCode::Digit5),
- "6" => Some(KeyCode::Digit6),
- "7" => Some(KeyCode::Digit7),
- "8" => Some(KeyCode::Digit8),
- "9" => Some(KeyCode::Digit9),
+ "a" | "keya" => Some(KeyCode::KeyA),
+ "b" | "keyb" => Some(KeyCode::KeyB),
+ "c" | "keyc" => Some(KeyCode::KeyC),
+ "d" | "keyd" => Some(KeyCode::KeyD),
+ "e" | "keye" => Some(KeyCode::KeyE),
+ "f" | "keyf" => Some(KeyCode::KeyF),
+ "g" | "keyg" => Some(KeyCode::KeyG),
+ "h" | "keyh" => Some(KeyCode::KeyH),
+ "i" | "keyi" => Some(KeyCode::KeyI),
+ "j" | "keyj" => Some(KeyCode::KeyJ),
+ "k" | "keyk" => Some(KeyCode::KeyK),
+ "l" | "keyl" => Some(KeyCode::KeyL),
+ "m" | "keym" => Some(KeyCode::KeyM),
+ "n" | "keyn" => Some(KeyCode::KeyN),
+ "o" | "keyo" => Some(KeyCode::KeyO),
+ "p" | "keyp" => Some(KeyCode::KeyP),
+ "q" | "keyq" => Some(KeyCode::KeyQ),
+ "r" | "keyr" => Some(KeyCode::KeyR),
+ "s" | "keys" => Some(KeyCode::KeyS),
+ "t" | "keyt" => Some(KeyCode::KeyT),
+ "u" | "keyu" => Some(KeyCode::KeyU),
+ "v" | "keyv" => Some(KeyCode::KeyV),
+ "w" | "keyw" => Some(KeyCode::KeyW),
+ "x" | "keyx" => Some(KeyCode::KeyX),
+ "y" | "keyy" => Some(KeyCode::KeyY),
+ "z" | "keyz" => Some(KeyCode::KeyZ),
+ "0" | "digit0" => Some(KeyCode::Digit0),
+ "1" | "digit1" => Some(KeyCode::Digit1),
+ "2" | "digit2" => Some(KeyCode::Digit2),
+ "3" | "digit3" => Some(KeyCode::Digit3),
+ "4" | "digit4" => Some(KeyCode::Digit4),
+ "5" | "digit5" => Some(KeyCode::Digit5),
+ "6" | "digit6" => Some(KeyCode::Digit6),
+ "7" | "digit7" => Some(KeyCode::Digit7),
+ "8" | "digit8" => Some(KeyCode::Digit8),
+ "9" | "digit9" => Some(KeyCode::Digit9),
"f1" => Some(KeyCode::F1),
"f2" => Some(KeyCode::F2),
"f3" => Some(KeyCode::F3),
@@ -1024,10 +1162,10 @@ fn parse_key_code(key: &str) -> Option {
"f10" => Some(KeyCode::F10),
"f11" => Some(KeyCode::F11),
"f12" => Some(KeyCode::F12),
- "up" => Some(KeyCode::ArrowUp),
- "down" => Some(KeyCode::ArrowDown),
- "left" => Some(KeyCode::ArrowLeft),
- "right" => Some(KeyCode::ArrowRight),
+ "up" | "arrowup" => Some(KeyCode::ArrowUp),
+ "down" | "arrowdown" => Some(KeyCode::ArrowDown),
+ "left" | "arrowleft" => Some(KeyCode::ArrowLeft),
+ "right" | "arrowright" => Some(KeyCode::ArrowRight),
"enter" => Some(KeyCode::Enter),
"tab" => Some(KeyCode::Tab),
"space" => Some(KeyCode::Space),
@@ -1088,6 +1226,209 @@ fn ctrl_keycode_byte(key: KeyCode) -> Option {
}
}
+#[cfg(test)]
+mod key_code_tests {
+ use super::*;
+
+ #[test]
+ fn parses_physical_key_names_from_default_config() {
+ assert_eq!(parse_key_code("Digit0"), Some(KeyCode::Digit0));
+ assert_eq!(parse_key_code("KeyP"), Some(KeyCode::KeyP));
+ assert_eq!(parse_key_code("ArrowUp"), Some(KeyCode::ArrowUp));
+ }
+
+ #[test]
+ fn default_camera_slot_bindings_cover_all_slots() {
+ let mut slots = default_bindings()
+ .into_iter()
+ .filter_map(|binding| binding.action.camera_slot())
+ .collect::>();
+ slots.sort_unstable();
+ assert_eq!(slots, (0..10).collect::>());
+ }
+
+ #[test]
+ fn default_bindings_have_no_duplicate_triggers() {
+ let bindings = default_bindings();
+ for (index, binding) in bindings.iter().enumerate() {
+ assert!(
+ bindings[index + 1..]
+ .iter()
+ .all(|other| !binding.same_trigger(other)),
+ "duplicate default trigger for {:?}",
+ binding.action
+ );
+ }
+ }
+
+ #[test]
+ fn camera_slot_zero_does_not_replace_font_reset() {
+ let bindings = TerminalKeyBindings {
+ bindings: default_bindings(),
+ };
+ let control_alt = BindingModifiers {
+ control: true,
+ alt: true,
+ ..default()
+ };
+ let control_alt_shift = BindingModifiers {
+ shift: true,
+ ..control_alt
+ };
+
+ assert_eq!(
+ bindings.action_for(KeyCode::Digit0, control_alt),
+ Some(BindingAction::ResetFontSize)
+ );
+ assert_eq!(
+ bindings.action_for(KeyCode::Digit0, control_alt_shift),
+ Some(BindingAction::ActivateCameraSlot0)
+ );
+ }
+
+ #[test]
+ fn distributed_camera_bindings_parse_without_trigger_collisions() {
+ let config: AppConfig =
+ toml::from_str(include_str!("../config/ratty.toml")).expect("distributed config");
+ let bindings = config
+ .bindings
+ .keys
+ .iter()
+ .map(|binding| KeyBinding::from_config(binding).expect("valid distributed binding"))
+ .collect::>();
+
+ for (index, binding) in bindings.iter().enumerate() {
+ assert!(
+ bindings[index + 1..]
+ .iter()
+ .all(|other| !binding.same_trigger(other)),
+ "duplicate distributed trigger for {:?}",
+ binding.action
+ );
+ }
+ let mut slots = bindings
+ .iter()
+ .filter_map(|binding| binding.action.camera_slot())
+ .collect::>();
+ slots.sort_unstable();
+ assert_eq!(slots, (0..10).collect::>());
+ }
+
+ #[test]
+ fn mobius_exit_uses_the_active_slots_protocol_pose() {
+ use crate::camera::{
+ OptionalVec3, TerminalCameraUpdate, activate_terminal_camera_presets,
+ apply_terminal_camera_updates,
+ };
+
+ let mut app = App::new();
+ app.init_resource::()
+ .init_resource::()
+ .init_resource::()
+ .add_message::()
+ .add_message::()
+ .add_systems(
+ Update,
+ (
+ apply_terminal_camera_updates,
+ activate_terminal_camera_presets,
+ )
+ .chain(),
+ );
+ app.world_mut().write_message(TerminalCameraUpdate {
+ slot: 2,
+ activate: true,
+ mode: Some(TerminalPresentationMode::Mobius3d),
+ scale: Some(2.5),
+ fov: None,
+ translation: OptionalVec3 {
+ x: Some(12.0),
+ y: Some(-8.0),
+ z: Some(30.0),
+ },
+ rotation_degrees: OptionalVec3 {
+ x: Some(15.0),
+ y: Some(35.0),
+ z: None,
+ },
+ });
+ app.update();
+ app.world_mut()
+ .write_message(ActivateTerminalCameraPreset { slot: 1 });
+ app.update();
+ app.world_mut()
+ .write_message(ActivateTerminalCameraPreset { slot: 2 });
+ app.update();
+
+ let expected = *app.world().resource::().active();
+ let mut slots = app
+ .world_mut()
+ .remove_resource::()
+ .expect("camera slots");
+ let mut interaction = app
+ .world_mut()
+ .remove_resource::()
+ .expect("camera interaction");
+ interaction.rotating = true;
+ let mut transition = app
+ .world_mut()
+ .remove_resource::()
+ .expect("mobius transition");
+
+ toggle_mobius_presentation(&mut slots, &mut interaction, &mut transition);
+
+ assert!(transition.active);
+ assert!(matches!(
+ transition.direction,
+ crate::scene::MobiusTransitionDirection::Exiting
+ ));
+ assert!(transition.source_is_for(2));
+ assert_eq!(transition.source_mode, TerminalPresentationMode::Flat2d);
+ assert_eq!(transition.end_zoom, expected.pose.orthographic_scale);
+ assert_eq!(transition.end_translation, expected.pose.translation);
+ assert_eq!(transition.end_yaw, expected.pose.yaw);
+ assert_eq!(transition.end_pitch, expected.pose.pitch);
+ assert!(!interaction.rotating);
+ }
+
+ #[test]
+ fn mobius_toggle_during_exit_turns_back_into_mobius() {
+ use crate::scene::MobiusTransitionDirection;
+
+ let mut slots = TerminalCameraSlots::default();
+ let mut interaction = TerminalCameraInteraction::default();
+ let mut transition = MobiusTransition::default();
+
+ toggle_mobius_presentation(&mut slots, &mut interaction, &mut transition);
+ assert_eq!(slots.active().mode, TerminalPresentationMode::Mobius3d);
+ transition.stop();
+
+ toggle_mobius_presentation(&mut slots, &mut interaction, &mut transition);
+ assert!(matches!(
+ transition.direction,
+ MobiusTransitionDirection::Exiting
+ ));
+ transition.elapsed_secs =
+ MobiusTransition::VIEW_RESET_SECS + MobiusTransition::MORPH_SECS * 0.5;
+ let morph_before = transition.morph_progress();
+
+ toggle_mobius_presentation(&mut slots, &mut interaction, &mut transition);
+
+ assert!(transition.active);
+ assert!(matches!(
+ transition.direction,
+ MobiusTransitionDirection::Entering
+ ));
+ assert!((transition.morph_progress() - morph_before).abs() < 1e-6);
+ assert_eq!(slots.active().mode, TerminalPresentationMode::Mobius3d);
+ assert_eq!(
+ slots.active().mobius_source.expect("Mobius source").mode,
+ TerminalPresentationMode::Flat2d
+ );
+ assert_eq!(transition.source_mode, TerminalPresentationMode::Flat2d);
+ }
+}
+
#[cfg(test)]
mod keyboard_translation_tests {
use super::*;
diff --git a/src/lib.rs b/src/lib.rs
index 64b4998..aab12ba 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -6,6 +6,7 @@
#![warn(missing_docs)]
#![warn(clippy::unwrap_used)]
+pub mod camera;
pub mod cli;
pub mod config;
mod direct_render;
diff --git a/src/mouse.rs b/src/mouse.rs
index 42caaba..59a8553 100644
--- a/src/mouse.rs
+++ b/src/mouse.rs
@@ -5,15 +5,16 @@ use bevy::ecs::system::SystemParam;
use bevy::input::ButtonState;
use bevy::input::mouse::{MouseButton, MouseButtonInput, MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
-use bevy::window::{CursorMoved, PrimaryWindow, Window};
+use bevy::window::{CursorMoved, PrimaryWindow, Window, WindowFocused};
use vt100::{MouseProtocolEncoding, MouseProtocolMode};
+use crate::camera::{
+ MAX_PERSPECTIVE_FOV, MIN_ORTHOGRAPHIC_SCALE, MIN_PERSPECTIVE_FOV, TerminalCameraInteraction,
+ TerminalCameraSlots,
+};
use crate::config::AppConfig;
use crate::runtime::TerminalRuntime;
-use crate::scene::{
- MobiusTransition, TerminalPlaneView, TerminalPresentation, TerminalPresentationMode,
- TerminalViewport,
-};
+use crate::scene::{MobiusTransition, TerminalPresentationMode, TerminalViewport};
use crate::terminal::TerminalSurface;
/// Distance in pixels the pointer must move with a pending selection to start dragging.
@@ -241,9 +242,9 @@ pub struct MouseSystemParams<'w, 's> {
runtime: ResMut<'w, TerminalRuntime>,
terminal: Res<'w, TerminalSurface>,
viewport: Res<'w, TerminalViewport>,
- presentation: Res<'w, TerminalPresentation>,
+ camera_slots: ResMut<'w, TerminalCameraSlots>,
+ camera_interaction: ResMut<'w, TerminalCameraInteraction>,
mobius_transition: Res<'w, MobiusTransition>,
- plane_view: ResMut<'w, TerminalPlaneView>,
selection: ResMut<'w, TerminalSelection>,
redraw: ResMut<'w, crate::terminal::TerminalRedrawState>,
app_config: Res<'w, AppConfig>,
@@ -254,6 +255,7 @@ pub(crate) fn handle_mouse_input(
mut cursor_events: MessageReader,
mut button_events: MessageReader,
mut wheel_events: MessageReader,
+ mut focus_events: MessageReader,
mut params: MouseSystemParams,
mut forwarded_mouse: Local,
mut local_scroll: Local,
@@ -263,9 +265,9 @@ pub(crate) fn handle_mouse_input(
runtime,
terminal,
viewport,
- presentation,
+ camera_slots,
+ camera_interaction,
mobius_transition,
- plane_view,
selection,
redraw,
app_config,
@@ -273,13 +275,39 @@ pub(crate) fn handle_mouse_input(
let Ok((primary_window, window)) = primary_window.single() else {
return;
};
+
let window_size = window.resolution.size().max(Vec2::ONE);
let mouse_mode = runtime.parser.screen().mouse_protocol_mode();
let mouse_encoding = runtime.parser.screen().mouse_protocol_encoding();
- let mobius_animating =
- presentation.mode == TerminalPresentationMode::Mobius3d && mobius_transition.active;
- let forward_mouse = presentation.mode == TerminalPresentationMode::Flat2d
- && mouse_mode != MouseProtocolMode::None;
+
+ // Button releases delivered while the window is unfocused never reach the
+ // handlers below, so losing focus mid-drag would otherwise leave held
+ // button state re-arming on bare cursor movement after refocus.
+ for event in focus_events.read() {
+ if event.window == primary_window && !event.focused {
+ // The PTY application saw the press and will never see the real
+ // release, so synthesize one per held button before dropping the
+ // local state.
+ if mouse_mode != MouseProtocolMode::None
+ && let Some(cell) = forwarded_mouse.last_cell
+ {
+ for (pressed, code) in [
+ (forwarded_mouse.left_pressed, 0),
+ (forwarded_mouse.middle_pressed, 1),
+ (forwarded_mouse.right_pressed, 2),
+ ] {
+ if pressed {
+ runtime.write_input(&encode_mouse_event(cell, code, true, mouse_encoding));
+ }
+ }
+ }
+ release_pointer_drags(camera_interaction, selection, &mut forwarded_mouse);
+ }
+ }
+ let mode = camera_slots.active().mode;
+ let mobius_animating = mode == TerminalPresentationMode::Mobius3d && mobius_transition.active;
+ let forward_mouse =
+ mode == TerminalPresentationMode::Flat2d && mouse_mode != MouseProtocolMode::None;
for event in cursor_events.read() {
if event.window != primary_window {
@@ -291,26 +319,28 @@ pub(crate) fn handle_mouse_input(
continue;
}
- if matches!(
- presentation.mode,
- TerminalPresentationMode::Plane3d | TerminalPresentationMode::Mobius3d
- ) {
- if plane_view.rotating {
- if let Some(last) = plane_view.last_rotate_cursor {
+ if mode.is_3d() {
+ if camera_interaction.rotating {
+ if let Some(last) = camera_interaction.last_rotate_cursor {
let delta = event.position - last;
- plane_view.yaw += delta.x * 0.005;
- plane_view.pitch -= delta.y * 0.005;
- redraw.request();
+ let pose = &mut camera_slots.active_mut().pose;
+ pose.yaw += delta.x * 0.005;
+ pose.pitch -= delta.y * 0.005;
}
- plane_view.last_rotate_cursor = Some(event.position);
- } else if plane_view.panning {
- if let Some(last) = plane_view.last_pan_cursor {
+ camera_interaction.last_rotate_cursor = Some(event.position);
+ } else if camera_interaction.panning {
+ if let Some(last) = camera_interaction.last_pan_cursor {
let delta = event.position - last;
- plane_view.camera_offset.x -= delta.x * plane_view.zoom;
- plane_view.camera_offset.y += delta.y * plane_view.zoom;
- redraw.request();
+ let pose = &mut camera_slots.active_mut().pose;
+ let movement_scale = if mode == TerminalPresentationMode::Perspective3d {
+ pose.perspective_fov
+ } else {
+ pose.orthographic_scale
+ };
+ pose.translation.x -= delta.x * movement_scale;
+ pose.translation.y += delta.y * movement_scale;
}
- plane_view.last_pan_cursor = Some(event.position);
+ camera_interaction.last_pan_cursor = Some(event.position);
}
} else if forward_mouse {
if let Some(cell) = position_to_cell(event.position, window_size, viewport, terminal)
@@ -373,12 +403,9 @@ pub(crate) fn handle_mouse_input(
runtime.write_input(&encode_mouse_event(cell, 0, false, mouse_encoding));
forwarded_mouse.last_cell = Some(cell);
}
- } else if matches!(
- presentation.mode,
- TerminalPresentationMode::Plane3d | TerminalPresentationMode::Mobius3d
- ) {
- plane_view.rotating = true;
- plane_view.last_rotate_cursor = selection.cursor_position();
+ } else if mode.is_3d() {
+ camera_interaction.rotating = true;
+ camera_interaction.last_rotate_cursor = selection.cursor_position();
} else if let Some(pos) = selection.cursor_position()
&& let Some(cell) = position_to_cell(pos, window_size, viewport, terminal)
&& selection.begin_pending(cell, pos)
@@ -399,12 +426,9 @@ pub(crate) fn handle_mouse_input(
runtime.write_input(&encode_mouse_event(cell, 0, true, mouse_encoding));
forwarded_mouse.last_cell = Some(cell);
}
- } else if matches!(
- presentation.mode,
- TerminalPresentationMode::Plane3d | TerminalPresentationMode::Mobius3d
- ) {
- plane_view.rotating = false;
- plane_view.last_rotate_cursor = selection.cursor_position();
+ } else if mode.is_3d() {
+ camera_interaction.rotating = false;
+ camera_interaction.last_rotate_cursor = selection.cursor_position();
} else {
let _ = selection.end();
}
@@ -461,33 +485,19 @@ pub(crate) fn handle_mouse_input(
forwarded_mouse.last_cell = Some(cell);
}
}
- (MouseButton::Right, ButtonState::Pressed)
- if matches!(
- presentation.mode,
- TerminalPresentationMode::Plane3d | TerminalPresentationMode::Mobius3d
- ) =>
- {
- plane_view.panning = true;
- plane_view.last_pan_cursor = selection.cursor_position();
+ (MouseButton::Right, ButtonState::Pressed) if mode.is_3d() => {
+ camera_interaction.panning = true;
+ camera_interaction.last_pan_cursor = selection.cursor_position();
}
- (MouseButton::Right, ButtonState::Released)
- if matches!(
- presentation.mode,
- TerminalPresentationMode::Plane3d | TerminalPresentationMode::Mobius3d
- ) =>
- {
- plane_view.panning = false;
- plane_view.last_pan_cursor = selection.cursor_position();
+ (MouseButton::Right, ButtonState::Released) if mode.is_3d() => {
+ camera_interaction.panning = false;
+ camera_interaction.last_pan_cursor = selection.cursor_position();
}
_ => {}
}
}
for event in wheel_events.read() {
- if mobius_animating {
- continue;
- }
-
let delta = match event.unit {
MouseScrollUnit::Line => event.y * 0.1,
MouseScrollUnit::Pixel => event.y * 0.001,
@@ -506,7 +516,7 @@ pub(crate) fn handle_mouse_input(
mouse_encoding,
));
}
- } else if presentation.mode == TerminalPresentationMode::Flat2d
+ } else if mode == TerminalPresentationMode::Flat2d
&& !runtime.parser.screen().alternate_screen()
{
let amount = match event.unit {
@@ -531,13 +541,13 @@ pub(crate) fn handle_mouse_input(
selection.clear();
redraw.request();
}
- } else if matches!(
- presentation.mode,
- TerminalPresentationMode::Plane3d | TerminalPresentationMode::Mobius3d
- ) && delta != 0.0
- {
- plane_view.zoom = (plane_view.zoom - delta).clamp(0.1, 4.0);
- redraw.request();
+ } else if mode.is_3d() && delta != 0.0 {
+ apply_wheel_zoom(
+ &mut camera_slots.active_mut().pose,
+ mode,
+ mobius_animating,
+ delta,
+ );
}
}
}
@@ -610,3 +620,143 @@ fn position_to_cell(
row.min(terminal.rows.saturating_sub(1) as u32),
))
}
+
+/// Drops every drag-like state that waits on a button release.
+///
+/// Releases delivered while the window is unfocused go to the newly focused
+/// window instead, so held-button state must be cleared on focus loss: camera
+/// drags, an in-progress or pending text selection drag (the completed
+/// selection itself is kept), and forwarded mouse-protocol button state.
+/// This only clears local state; the caller synthesizes the release events
+/// the PTY application is still owed before invoking it. `last_cell` is also
+/// cleared so the first motion after refocus is never deduplicated away.
+fn release_pointer_drags(
+ camera_interaction: &mut TerminalCameraInteraction,
+ selection: &mut TerminalSelection,
+ forwarded_mouse: &mut ForwardedMouseState,
+) {
+ camera_interaction.reset();
+ selection.end();
+ forwarded_mouse.left_pressed = false;
+ forwarded_mouse.middle_pressed = false;
+ forwarded_mouse.right_pressed = false;
+ forwarded_mouse.last_cell = None;
+}
+
+/// Largest orthographic scale reachable by wheel zoom alone.
+const MAX_WHEEL_ORTHOGRAPHIC_SCALE: f32 = 4.0;
+
+/// Applies one wheel zoom step to an orthographic scale.
+///
+/// The protocol accepts any scale of at least [`MIN_ORTHOGRAPHIC_SCALE`], so a
+/// protocol-set scale above the interactive limit must not be yanked down to
+/// it by the first wheel tick; the wheel can only zoom back toward the range.
+fn wheel_zoomed_orthographic_scale(current: f32, delta: f32) -> f32 {
+ let max_scale = current.max(MAX_WHEEL_ORTHOGRAPHIC_SCALE);
+ (current - delta).clamp(MIN_ORTHOGRAPHIC_SCALE, max_scale)
+}
+
+/// Routes one wheel step to the projection value the mode displays.
+///
+/// Mobius shares the orthographic branch with Plane3d. Input is dropped while
+/// a Mobius transition animates: the transition owns the strip zoom until it
+/// finishes.
+fn apply_wheel_zoom(
+ pose: &mut crate::camera::TerminalCameraPose,
+ mode: TerminalPresentationMode,
+ mobius_animating: bool,
+ delta: f32,
+) {
+ if !mode.is_3d() || mobius_animating || delta == 0.0 {
+ return;
+ }
+ if mode == TerminalPresentationMode::Perspective3d {
+ pose.perspective_fov =
+ (pose.perspective_fov - delta).clamp(MIN_PERSPECTIVE_FOV, MAX_PERSPECTIVE_FOV);
+ } else {
+ pose.orthographic_scale = wheel_zoomed_orthographic_scale(pose.orthographic_scale, delta);
+ }
+}
+
+#[cfg(test)]
+mod wheel_zoom_tests {
+ use super::*;
+ use crate::camera::TerminalCameraPose;
+
+ #[test]
+ fn focus_loss_releases_every_pointer_drag() {
+ let mut interaction = TerminalCameraInteraction {
+ rotating: true,
+ panning: true,
+ last_rotate_cursor: Some(Vec2::ONE),
+ last_pan_cursor: Some(Vec2::ONE),
+ };
+ let mut selection = TerminalSelection::default();
+ selection.begin(UVec2::new(2, 3));
+ let mut forwarded = ForwardedMouseState {
+ left_pressed: true,
+ middle_pressed: true,
+ right_pressed: true,
+ last_cell: Some(UVec2::ZERO),
+ };
+
+ release_pointer_drags(&mut interaction, &mut selection, &mut forwarded);
+
+ assert!(!interaction.rotating);
+ assert!(!interaction.panning);
+ assert_eq!(interaction.last_rotate_cursor, None);
+ assert!(!selection.dragging);
+ // The completed selection itself survives; only the drag is released.
+ assert!(selection.normalized_bounds().is_some());
+ assert!(!forwarded.left_pressed);
+ assert!(!forwarded.middle_pressed);
+ assert!(!forwarded.right_pressed);
+ assert_eq!(forwarded.last_cell, None);
+
+ let mut pending = TerminalSelection::default();
+ pending.begin_pending(UVec2::ZERO, Vec2::ZERO);
+ release_pointer_drags(&mut interaction, &mut pending, &mut forwarded);
+ assert_eq!(pending.pending_start, None);
+ assert!(!pending.update_from_cursor(UVec2::new(5, 5), Vec2::new(100.0, 100.0)));
+ }
+
+ #[test]
+ fn mobius_wheel_zoom_shares_the_orthographic_branch() {
+ let mut pose = TerminalCameraPose::default();
+ apply_wheel_zoom(&mut pose, TerminalPresentationMode::Mobius3d, false, 0.1);
+ assert_eq!(pose.orthographic_scale, 0.9);
+ assert_eq!(
+ pose.perspective_fov,
+ TerminalCameraPose::default().perspective_fov
+ );
+
+ let mut plane_pose = TerminalCameraPose::default();
+ apply_wheel_zoom(
+ &mut plane_pose,
+ TerminalPresentationMode::Plane3d,
+ false,
+ 0.1,
+ );
+ assert_eq!(plane_pose.orthographic_scale, pose.orthographic_scale);
+
+ // While the Mobius transition animates, the wheel is ignored.
+ let before = pose;
+ apply_wheel_zoom(&mut pose, TerminalPresentationMode::Mobius3d, true, 0.1);
+ assert_eq!(pose, before);
+ }
+
+ #[test]
+ fn wheel_zoom_respects_the_protocol_scale_range() {
+ // A protocol-set scale above the interactive cap is not snapped down.
+ assert_eq!(wheel_zoomed_orthographic_scale(20.0, -0.1), 20.0);
+ // Zooming in from a large scale moves toward the view, not to 4.0.
+ assert_eq!(wheel_zoomed_orthographic_scale(20.0, 0.1), 19.9);
+ // Zooming in near the protocol minimum never zooms out instead.
+ let zoomed = wheel_zoomed_orthographic_scale(0.05, 0.1);
+ assert!(zoomed <= 0.05);
+ assert!(zoomed >= MIN_ORTHOGRAPHIC_SCALE);
+ // The ordinary interactive range still behaves as before.
+ assert_eq!(wheel_zoomed_orthographic_scale(1.0, 0.1), 0.9);
+ assert_eq!(wheel_zoomed_orthographic_scale(3.95, -0.1), 4.0);
+ }
+}
diff --git a/src/plugin.rs b/src/plugin.rs
index db2b715..bd3a1a2 100644
--- a/src/plugin.rs
+++ b/src/plugin.rs
@@ -2,6 +2,10 @@
use bevy::prelude::*;
+use crate::camera::{
+ ActivateTerminalCameraPreset, TerminalCameraSlots, TerminalCameraSystemSet,
+ TerminalCameraUpdate, activate_terminal_camera_presets, apply_terminal_camera_updates,
+};
use crate::config::AppConfig;
use crate::direct_render::DirectTerminalRenderPlugin;
use crate::inline::{
@@ -11,8 +15,7 @@ use crate::keyboard::{TerminalClipboard, TerminalKeyBindings, handle_keyboard_in
use crate::mouse::{TerminalSelection, handle_mouse_input};
use crate::present::TerminalPresentPlugin;
use crate::scene::{
- MobiusTransition, TerminalPlaneView, TerminalPresentation, TerminalPresentationMode,
- apply_terminal_presentation, setup_scene,
+ MobiusTransition, TerminalPresentationMode, apply_terminal_presentation, setup_scene,
};
use crate::systems::{
TerminalFrameDirty, TerminalRedrawSet, animate_inline_kitty_planes, animate_mobius_transition,
@@ -45,42 +48,79 @@ impl Plugin for TerminalPlugin {
.init_resource::()
.init_resource::()
.init_non_send::()
+ .add_message::()
+ .add_message::()
.add_systems(Startup, setup_scene)
.add_systems(Update, request_exit_on_primary_window_close)
.add_systems(Update, pump_pty_output)
- .add_systems(Update, handle_keyboard_input)
- .add_systems(Update, handle_mouse_input)
+ .configure_sets(
+ Update,
+ (
+ TerminalCameraSystemSet::ProtocolUpdates,
+ TerminalCameraSystemSet::KeyboardInput,
+ TerminalCameraSystemSet::Activation,
+ TerminalCameraSystemSet::MouseInput,
+ TerminalCameraSystemSet::Transition,
+ TerminalCameraSystemSet::Synchronize,
+ )
+ .chain(),
+ )
+ .add_systems(
+ Update,
+ apply_terminal_camera_updates
+ .after(pump_pty_output)
+ .in_set(TerminalCameraSystemSet::ProtocolUpdates),
+ )
+ .add_systems(
+ Update,
+ handle_keyboard_input.in_set(TerminalCameraSystemSet::KeyboardInput),
+ )
+ .add_systems(
+ Update,
+ activate_terminal_camera_presets.in_set(TerminalCameraSystemSet::Activation),
+ )
+ .add_systems(
+ Update,
+ handle_mouse_input.in_set(TerminalCameraSystemSet::MouseInput),
+ )
+ .add_systems(
+ Update,
+ animate_mobius_transition
+ .run_if(
+ |camera_slots: Res,
+ mobius_transition: Res| {
+ camera_slots.active().mode == TerminalPresentationMode::Mobius3d
+ || mobius_transition.active
+ },
+ )
+ .in_set(TerminalCameraSystemSet::Transition),
+ )
.add_systems(Update, handle_window_resize)
.add_systems(
Update,
apply_terminal_presentation
- .after(handle_keyboard_input)
- .after(handle_mouse_input)
.run_if(
- |presentation: Res,
- plane_view: Res,
+ |camera_slots: Res,
mobius_transition: Res| {
- presentation.is_changed()
- || plane_view.is_changed()
- || mobius_transition.is_changed()
+ camera_slots.is_changed() || mobius_transition.is_changed()
},
- ),
+ )
+ .in_set(TerminalCameraSystemSet::Synchronize),
)
.add_systems(
Update,
apply_inline_objects
.after(apply_terminal_presentation)
.run_if(
- |presentation: Res, added: AddedInlineObjects| {
- presentation.is_changed() || !added.is_empty()
+ |camera_slots: Res, added: AddedInlineObjects| {
+ camera_slots.is_changed() || !added.is_empty()
},
),
)
.configure_sets(
Update,
TerminalRedrawSet
- .after(handle_mouse_input)
- .after(handle_keyboard_input)
+ .after(TerminalCameraSystemSet::MouseInput)
.after(handle_window_resize)
.after(pump_pty_output),
)
@@ -94,38 +134,42 @@ impl Plugin for TerminalPlugin {
.chain()
.in_set(TerminalRedrawSet),
)
- .add_systems(Update, sync_inline_objects.after(TerminalRedrawSet))
.add_systems(
Update,
- animate_inline_kitty_planes.after(sync_inline_objects),
+ sync_inline_objects
+ .after(TerminalRedrawSet)
+ // Deterministic vs the Transition set: on the frame a
+ // Mobius exit finishes, spawned inline entities must see
+ // the restored mode, not race it.
+ .after(TerminalCameraSystemSet::Transition),
)
.add_systems(
Update,
- sync_rgp_objects
+ animate_inline_kitty_planes
.after(sync_inline_objects)
- .run_if(|objects: Query<(), With>| !objects.is_empty()),
+ .after(TerminalCameraSystemSet::Transition),
)
- .add_systems(Update, apply_instance_brightness.after(sync_rgp_objects))
.add_systems(
Update,
- animate_mobius_transition.run_if(
- |presentation: Res,
- mobius_transition: Res| {
- presentation.mode == TerminalPresentationMode::Mobius3d
- || mobius_transition.active
- },
- ),
+ sync_rgp_objects
+ .after(sync_inline_objects)
+ .after(TerminalCameraSystemSet::Synchronize)
+ .run_if(|objects: Query<(), With>| !objects.is_empty()),
)
+ .add_systems(Update, apply_instance_brightness.after(sync_rgp_objects))
.add_systems(
Update,
- animate_terminal_plane_warp.run_if(|presentation: Res| {
- presentation.mode != TerminalPresentationMode::Flat2d
- }),
+ animate_terminal_plane_warp
+ .after(TerminalCameraSystemSet::Transition)
+ .run_if(|camera_slots: Res| {
+ camera_slots.active().mode != TerminalPresentationMode::Flat2d
+ }),
)
.add_systems(
Update,
sync_asset_to_terminal_cursor
.after(TerminalRedrawSet)
+ .after(TerminalCameraSystemSet::Synchronize)
.run_if(|config: Res| config.cursor.model.visible),
)
.add_systems(Last, shutdown_terminal_runtime_on_exit)
diff --git a/src/rgp.rs b/src/rgp.rs
index 471600b..92e6d4e 100644
--- a/src/rgp.rs
+++ b/src/rgp.rs
@@ -1,6 +1,9 @@
//! Ratty Graphics Protocol parsing.
use base64::Engine as _;
+use bevy::prelude::*;
+
+use crate::scene::TerminalPresentationMode;
/// Ratty Graphics Protocol APC prefix.
pub const RGP_APC_START: &[u8] = b"\x1b_ratty;g;";
@@ -28,8 +31,28 @@ pub struct RgpPlacementStyle {
pub scale3: [f32; 3],
}
-/// Partial update for an RGP object placement.
+/// Partial camera settings parsed from a `c` command.
+///
+/// `None` fields leave the slot's stored value untouched. Every angle on the
+/// wire is degrees; `fov` is converted to radians during parsing, so nothing
+/// downstream ever handles a degree value.
#[derive(Clone, Copy, Default)]
+pub struct RgpCameraSettings {
+ /// Camera type: flat, orthographic, perspective, or Mobius.
+ pub camera_type: Option,
+ /// Orthographic projection scale.
+ pub scale: Option,
+ /// Vertical perspective FOV in radians. The wire value is degrees and is
+ /// converted in [`consume_sequence`]; never assign a degree value here.
+ pub fov: Option,
+ /// Translation offset relative to the terminal plane.
+ pub offset: [Option; 3],
+ /// Rotation in degrees, ordered as pitch, yaw, and roll.
+ pub rotation: [Option; 3],
+}
+
+/// Partial update for an RGP object placement.
+#[derive(Clone, Copy)]
pub struct RgpPlacementUpdate {
/// Updates the default animation flag.
pub animate: Option,
@@ -99,15 +122,19 @@ pub fn consume_sequence(sequence: &[u8]) -> Option {
let mut id = None;
let mut format = None;
let mut path = None;
+ let mut ctype = None;
let mut source = None;
let mut more = None;
let mut name = None;
+ let mut set = None;
+ let mut invalid_camera_field = false;
let mut row = None;
let mut col = None;
let mut width = None;
let mut height = None;
let mut animate = None;
let mut scale = None;
+ let mut fov = None;
let mut depth = None;
let mut color = None;
let mut brightness = None;
@@ -128,30 +155,67 @@ pub fn consume_sequence(sequence: &[u8]) -> Option {
payload = Some(part.to_string());
break;
}
+ // A value-less token in a camera command is a truncated or broken
+ // emitter; reject the whole command instead of partially applying.
+ invalid_camera_field |= verb == "c";
continue;
};
match key {
- "id" => id = value.parse().ok(),
+ "id" => {
+ id = value.parse().ok();
+ invalid_camera_field |= verb == "c" && id.is_none();
+ }
"fmt" => format = Some(value.to_string()),
"path" => path = Some(value.to_string()),
+ "type" => ctype = Some(value.to_string()),
"source" => source = Some(value.to_string()),
"more" => more = parse_bool(value),
"name" => name = Some(value.to_string()),
+ "set" => {
+ set = parse_bool(value);
+ invalid_camera_field |= verb == "c" && set.is_none();
+ }
"row" => row = value.parse().ok(),
"col" => col = value.parse().ok(),
"w" => width = value.parse().ok(),
"h" => height = value.parse().ok(),
"animate" => animate = parse_bool(value),
- "scale" => scale = value.parse().ok(),
+ "scale" => {
+ scale = parse_finite_f32(value);
+ invalid_camera_field |= verb == "c" && scale.is_none();
+ }
+ "fov" => {
+ // Degrees on the wire; radians everywhere past this point.
+ fov = parse_finite_f32(value).map(f32::to_radians);
+ invalid_camera_field |= verb == "c" && fov.is_none();
+ }
"depth" => depth = value.parse().ok(),
"color" | "tint" => color = parse_color(value),
"brightness" => brightness = value.parse().ok(),
- "px" => px = value.parse().ok(),
- "py" => py = value.parse().ok(),
- "pz" => pz = value.parse().ok(),
- "rx" => rx = value.parse().ok(),
- "ry" => ry = value.parse().ok(),
- "rz" => rz = value.parse().ok(),
+ "px" => {
+ px = parse_finite_f32(value);
+ invalid_camera_field |= verb == "c" && px.is_none();
+ }
+ "py" => {
+ py = parse_finite_f32(value);
+ invalid_camera_field |= verb == "c" && py.is_none();
+ }
+ "pz" => {
+ pz = parse_finite_f32(value);
+ invalid_camera_field |= verb == "c" && pz.is_none();
+ }
+ "rx" => {
+ rx = parse_finite_f32(value);
+ invalid_camera_field |= verb == "c" && rx.is_none();
+ }
+ "ry" => {
+ ry = parse_finite_f32(value);
+ invalid_camera_field |= verb == "c" && ry.is_none();
+ }
+ "rz" => {
+ rz = parse_finite_f32(value);
+ invalid_camera_field |= verb == "c" && rz.is_none();
+ }
"sx" => sx = value.parse().ok(),
"sy" => sy = value.parse().ok(),
"sz" => sz = value.parse().ok(),
@@ -166,6 +230,33 @@ pub fn consume_sequence(sequence: &[u8]) -> Option {
match verb {
"s" => Some(RgpOperation::SupportQuery),
+ "c" => {
+ let Some(camera_slot) = id.filter(|slot| *slot < 10) else {
+ return Some(RgpOperation::Ignored);
+ };
+ let camera_type = match ctype.as_deref() {
+ Some("Flat") => Some(TerminalPresentationMode::Flat2d),
+ Some("Ortho") => Some(TerminalPresentationMode::Plane3d),
+ Some("Persp") => Some(TerminalPresentationMode::Perspective3d),
+ Some("Mobius") => Some(TerminalPresentationMode::Mobius3d),
+ Some(_) => return Some(RgpOperation::Ignored),
+ None => None,
+ };
+ if invalid_camera_field {
+ return Some(RgpOperation::Ignored);
+ }
+ Some(RgpOperation::Camera {
+ camera_slot,
+ switch_immediately: set.unwrap_or(false),
+ settings: RgpCameraSettings {
+ camera_type,
+ scale,
+ fov,
+ offset: [px, py, pz],
+ rotation: [rx, ry, rz],
+ },
+ })
+ }
"r" => Some(RgpOperation::Register {
object_id: id?,
format: format?,
@@ -255,6 +346,15 @@ pub enum RgpOperation {
/// Register source.
source: RgpRegisterSource,
},
+ /// Camera manipulation.
+ Camera {
+ /// Camera preset slot, `0` through `9`.
+ camera_slot: u32,
+ /// Activates the slot after applying the update (`set=1`, default `0`).
+ switch_immediately: bool,
+ /// The partial settings: mode, scale, FOV, rotation, and offset.
+ settings: RgpCameraSettings,
+ },
/// Object placement.
Place {
/// Object identifier.
@@ -280,7 +380,7 @@ pub enum RgpOperation {
/// Returns the RGP support reply sequence.
pub fn support_reply() -> Vec {
- b"\x1b_ratty;g;s;v=1;fmt=obj|glb|stl;path=1;payload=1;chunk=1;anim=1;depth=1;color=1;brightness=1;transform=1;update=1;normalize=1\x1b\\".to_vec()
+ b"\x1b_ratty;g;s;v=1;fmt=obj|glb|stl;path=1;payload=1;chunk=1;anim=1;depth=1;color=1;brightness=1;transform=1;update=1;normalize=1;camera=1\x1b\\".to_vec()
}
fn parse_color(value: &str) -> Option<[u8; 3]> {
@@ -303,3 +403,88 @@ fn parse_bool(value: &str) -> Option {
_ => None,
}
}
+
+fn parse_finite_f32(value: &str) -> Option {
+ value.parse::().ok().filter(|value| value.is_finite())
+}
+
+#[cfg(test)]
+mod camera_tests {
+ use super::*;
+
+ fn parse(fields: &str) -> RgpOperation {
+ let sequence = format!("\x1b_ratty;g;c;{fields}\x1b\\");
+ consume_sequence(sequence.as_bytes()).expect("RGP sequence")
+ }
+
+ #[test]
+ fn parses_complete_camera_update() {
+ let operation =
+ parse("id=9;set=1;type=Persp;scale=1.2;fov=90;px=1;py=2;pz=3;rx=10;ry=20;rz=30");
+ let RgpOperation::Camera {
+ camera_slot,
+ switch_immediately,
+ settings,
+ } = operation
+ else {
+ panic!("expected camera update");
+ };
+ assert_eq!(camera_slot, 9);
+ assert!(switch_immediately);
+ assert_eq!(
+ settings.camera_type,
+ Some(TerminalPresentationMode::Perspective3d)
+ );
+ assert_eq!(settings.scale, Some(1.2));
+ assert_eq!(settings.fov, Some(90.0_f32.to_radians()));
+ assert_eq!(settings.offset, [Some(1.0), Some(2.0), Some(3.0)]);
+ assert_eq!(settings.rotation, [Some(10.0), Some(20.0), Some(30.0)]);
+ }
+
+ #[test]
+ fn preserves_partial_camera_updates() {
+ let RgpOperation::Camera { settings, .. } = parse("id=2;py=-4") else {
+ panic!("expected camera update");
+ };
+ assert_eq!(settings.camera_type, None);
+ assert_eq!(settings.scale, None);
+ assert_eq!(settings.fov, None);
+ assert_eq!(settings.offset, [None, Some(-4.0), None]);
+ assert_eq!(settings.rotation, [None, None, None]);
+ }
+
+ #[test]
+ fn rejects_invalid_slot_mode_boolean_and_numbers() {
+ for fields in [
+ "id=10",
+ "id=bad",
+ "id=0;type=FishEye",
+ "id=0;set=2",
+ "id=0;scale=nope",
+ "id=0;fov=nope",
+ "id=0;fov=NaN",
+ "id=0;fov",
+ "id=0;px=NaN",
+ "id=0;ry=inf",
+ "id=0;px",
+ "id=0;px;py=2",
+ ] {
+ assert!(matches!(parse(fields), RgpOperation::Ignored), "{fields}");
+ }
+ }
+
+ #[test]
+ fn accepts_all_camera_modes() {
+ for (name, expected) in [
+ ("Flat", TerminalPresentationMode::Flat2d),
+ ("Ortho", TerminalPresentationMode::Plane3d),
+ ("Persp", TerminalPresentationMode::Perspective3d),
+ ("Mobius", TerminalPresentationMode::Mobius3d),
+ ] {
+ let RgpOperation::Camera { settings, .. } = parse(&format!("id=0;type={name}")) else {
+ panic!("expected camera update");
+ };
+ assert_eq!(settings.camera_type, Some(expected));
+ }
+ }
+}
diff --git a/src/scene/mobius.rs b/src/scene/mobius.rs
index 2345841..409eddd 100644
--- a/src/scene/mobius.rs
+++ b/src/scene/mobius.rs
@@ -2,7 +2,31 @@
use bevy::prelude::*;
-use super::{TerminalPlaneView, TerminalPresentationMode};
+use crate::camera::{MIN_ORTHOGRAPHIC_SCALE, TerminalCameraPose, TerminalMobiusSource};
+
+use super::TerminalPresentationMode;
+
+/// Zoom floor applied when an enter transition finishes.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum MobiusEnterZoomFloor {
+ /// Zoom out to at least [`MobiusTransition::TARGET_ZOOM_MULTIPLIER`] so
+ /// the keyboard toggle always reveals the whole strip; the finish
+ /// write-back may raise a sub-unit preset scale.
+ KeyboardTarget,
+ /// Honor the stored preset scale exactly, so the finish write-back is a
+ /// value-level no-op and protocol or activation enters never mutate the
+ /// preset.
+ ProtocolExact,
+}
+
+impl MobiusEnterZoomFloor {
+ fn min_end_zoom(self) -> f32 {
+ match self {
+ Self::KeyboardTarget => MobiusTransition::TARGET_ZOOM_MULTIPLIER,
+ Self::ProtocolExact => MIN_ORTHOGRAPHIC_SCALE,
+ }
+ }
+}
/// Animated transition into the Mobius-strip terminal view.
#[derive(Resource)]
@@ -15,14 +39,20 @@ pub struct MobiusTransition {
pub direction: MobiusTransitionDirection,
/// Source mode before entering the Mobius view.
pub source_mode: TerminalPresentationMode,
+ /// Camera slot that owns the saved source state.
+ source_slot: Option,
/// Source zoom before entering the Mobius view.
pub source_zoom: f32,
/// Source camera yaw before entering the Mobius view.
pub source_yaw: f32,
/// Source camera pitch before entering the Mobius view.
pub source_pitch: f32,
- /// Source camera pan offset before entering the Mobius view.
- pub source_camera_offset: Vec2,
+ /// Source camera roll before entering the Mobius view.
+ pub source_roll: f32,
+ /// Source camera translation before entering the Mobius view.
+ pub source_translation: Vec3,
+ /// Strip morph progress at the start of the active transition.
+ pub start_morph: f32,
/// Camera zoom at the start of the active transition.
pub start_zoom: f32,
/// Camera zoom at the end of the active transition.
@@ -31,14 +61,18 @@ pub struct MobiusTransition {
pub start_yaw: f32,
/// Camera pitch at the start of the active transition.
pub start_pitch: f32,
- /// Camera pan offset at the start of the active transition.
- pub start_camera_offset: Vec2,
+ /// Camera roll at the start of the active transition.
+ pub start_roll: f32,
+ /// Camera translation at the start of the active transition.
+ pub start_translation: Vec3,
/// Camera yaw at the end of the active transition.
pub end_yaw: f32,
/// Camera pitch at the end of the active transition.
pub end_pitch: f32,
- /// Camera pan offset at the end of the active transition.
- pub end_camera_offset: Vec2,
+ /// Camera roll at the end of the active transition.
+ pub end_roll: f32,
+ /// Camera translation at the end of the active transition.
+ pub end_translation: Vec3,
}
/// Direction of the Mobius transition.
@@ -60,43 +94,109 @@ impl MobiusTransition {
/// Final zoom multiplier applied when the transition completes.
pub const TARGET_ZOOM_MULTIPLIER: f32 = 1.0;
- /// Starts the entry transition from a source mode and zoom level.
+ /// Starts the entry transition toward `live_pose`, restoring to `source`
+ /// on a later exit.
+ ///
+ /// When a transition is already active for `slot` (e.g. re-entering
+ /// during an exit), the animation resumes from the currently displayed
+ /// values and keeps the stored source instead of overwriting it.
pub fn begin_enter(
&mut self,
- source_mode: TerminalPresentationMode,
- plane_view: &TerminalPlaneView,
+ slot: usize,
+ source: &TerminalMobiusSource,
+ live_pose: &TerminalCameraPose,
+ zoom_floor: MobiusEnterZoomFloor,
) {
+ let resume = self.active && self.source_is_for(slot);
+ if resume
+ && self.direction == MobiusTransitionDirection::Entering
+ && self.end_zoom == live_pose.orthographic_scale.max(zoom_floor.min_end_zoom())
+ && self.end_yaw == live_pose.yaw
+ && self.end_pitch == live_pose.pitch
+ && self.end_roll == live_pose.roll
+ && self.end_translation == live_pose.translation
+ {
+ // Re-entering toward identical targets is a no-op; restarting
+ // would reset the clock, letting rapid repeated activations keep
+ // the transition open (and input gated) indefinitely.
+ return;
+ }
+ let (start_morph, start_zoom, start_yaw, start_pitch, start_roll, start_translation) =
+ if resume {
+ (
+ self.morph_progress(),
+ self.current_zoom(),
+ self.current_yaw(),
+ self.current_pitch(),
+ self.current_roll(),
+ self.current_translation(),
+ )
+ } else {
+ (
+ 0.0,
+ live_pose.orthographic_scale,
+ live_pose.yaw,
+ live_pose.pitch,
+ live_pose.roll,
+ live_pose.translation,
+ )
+ };
+ if !resume {
+ self.prepare_source(slot, source.mode, &source.pose);
+ }
self.active = true;
self.elapsed_secs = 0.0;
self.direction = MobiusTransitionDirection::Entering;
- self.source_mode = source_mode;
- self.source_zoom = plane_view.zoom;
- self.source_yaw = plane_view.yaw;
- self.source_pitch = plane_view.pitch;
- self.source_camera_offset = plane_view.camera_offset;
- self.start_zoom = plane_view.zoom;
- self.end_zoom = plane_view.zoom.max(Self::TARGET_ZOOM_MULTIPLIER);
- self.start_yaw = plane_view.yaw;
- self.start_pitch = plane_view.pitch;
- self.start_camera_offset = plane_view.camera_offset;
- self.end_yaw = plane_view.yaw;
- self.end_pitch = plane_view.pitch;
- self.end_camera_offset = plane_view.camera_offset;
+ self.start_morph = start_morph;
+ self.start_zoom = start_zoom;
+ self.end_zoom = live_pose.orthographic_scale.max(zoom_floor.min_end_zoom());
+ self.start_yaw = start_yaw;
+ self.start_pitch = start_pitch;
+ self.start_roll = start_roll;
+ self.start_translation = start_translation;
+ self.end_yaw = live_pose.yaw;
+ self.end_pitch = live_pose.pitch;
+ self.end_roll = live_pose.roll;
+ self.end_translation = live_pose.translation;
}
/// Starts the exit transition back to the source mode.
- pub fn begin_exit(&mut self, plane_view: &TerminalPlaneView, current_zoom: f32) {
+ ///
+ /// When a transition is already active (e.g. exiting during an enter), the
+ /// animation resumes from the currently displayed values instead of
+ /// snapping to the fully formed strip.
+ pub fn begin_exit(&mut self, slot: usize, pose: &TerminalCameraPose, current_zoom: f32) {
+ // Resume only from a transition that belongs to this slot; another
+ // slot's in-flight values are never valid start values here.
+ let resume = self.active && self.source_is_for(slot);
+ if !self.source_is_for(slot) {
+ self.prepare_source(slot, TerminalPresentationMode::Plane3d, pose);
+ }
+ let (start_morph, start_yaw, start_pitch, start_roll, start_translation) = if resume {
+ (
+ self.morph_progress(),
+ self.current_yaw(),
+ self.current_pitch(),
+ self.current_roll(),
+ self.current_translation(),
+ )
+ } else {
+ (1.0, pose.yaw, pose.pitch, pose.roll, pose.translation)
+ };
self.active = true;
self.elapsed_secs = 0.0;
self.direction = MobiusTransitionDirection::Exiting;
+ self.start_morph = start_morph;
self.start_zoom = current_zoom;
- self.end_zoom = self.source_zoom.max(0.1);
- self.start_yaw = plane_view.yaw;
- self.start_pitch = plane_view.pitch;
- self.start_camera_offset = plane_view.camera_offset;
+ self.end_zoom = self.source_zoom.max(MIN_ORTHOGRAPHIC_SCALE);
+ self.start_yaw = start_yaw;
+ self.start_pitch = start_pitch;
+ self.start_roll = start_roll;
+ self.start_translation = start_translation;
self.end_yaw = self.source_yaw;
self.end_pitch = self.source_pitch;
- self.end_camera_offset = self.source_camera_offset;
+ self.end_roll = self.source_roll;
+ self.end_translation = self.source_translation;
}
/// Stops the transition and resets its timer.
@@ -105,23 +205,85 @@ impl MobiusTransition {
self.elapsed_secs = 0.0;
}
+ /// Saves a stable source pose for a later Mobius exit from `slot`.
+ pub fn prepare_source(
+ &mut self,
+ slot: usize,
+ source_mode: TerminalPresentationMode,
+ pose: &TerminalCameraPose,
+ ) {
+ self.source_slot = Some(slot);
+ self.source_mode = if source_mode == TerminalPresentationMode::Mobius3d {
+ TerminalPresentationMode::Plane3d
+ } else {
+ source_mode
+ };
+ self.source_zoom = pose.orthographic_scale;
+ self.source_yaw = pose.yaw;
+ self.source_pitch = pose.pitch;
+ self.source_roll = pose.roll;
+ self.source_translation = pose.translation;
+ }
+
+ /// Returns whether the saved source state belongs to `slot`.
+ pub fn source_is_for(&self, slot: usize) -> bool {
+ self.source_slot == Some(slot)
+ }
+
/// Returns the current zoom-out progress from `0.0` to `1.0` while entering.
pub fn enter_zoom_progress(&self) -> f32 {
(self.elapsed_secs / Self::ZOOM_OUT_SECS).clamp(0.0, 1.0)
}
- /// Returns the current Mobius morph progress from `0.0` to `1.0` while entering.
- pub fn enter_morph_progress(&self) -> f32 {
- ((self.elapsed_secs - Self::ZOOM_OUT_SECS) / Self::MORPH_SECS).clamp(0.0, 1.0)
+ /// Returns the morph phase duration for the active transition.
+ ///
+ /// A resumed transition covers only the remaining morph distance, so the
+ /// phase shrinks proportionally and morph velocity stays continuous
+ /// across interruptions instead of stretching the remainder over the full
+ /// [`Self::MORPH_SECS`].
+ fn morph_phase_secs(&self) -> f32 {
+ let remaining = match self.direction {
+ MobiusTransitionDirection::Entering => 1.0 - self.start_morph,
+ MobiusTransitionDirection::Exiting => self.start_morph,
+ };
+ Self::MORPH_SECS * remaining
+ }
+
+ /// Returns how long the morph waits for the camera phase.
+ ///
+ /// Only fresh transitions hold the morph while the camera settles; a
+ /// resumed transition must keep morphing immediately, otherwise every
+ /// restart (e.g. rapid slot hand-offs) would re-freeze the morph for the
+ /// hold duration and the transition could be kept from ever finishing.
+ fn morph_hold_secs(&self) -> f32 {
+ match self.direction {
+ MobiusTransitionDirection::Entering if self.start_morph > 0.0 => 0.0,
+ MobiusTransitionDirection::Entering => Self::ZOOM_OUT_SECS,
+ MobiusTransitionDirection::Exiting if self.start_morph < 1.0 => 0.0,
+ MobiusTransitionDirection::Exiting => Self::VIEW_RESET_SECS,
+ }
+ }
+
+ /// Returns the raw progress through the (possibly shortened) morph phase.
+ fn morph_phase_progress(&self) -> f32 {
+ let phase_secs = self.morph_phase_secs();
+ if phase_secs <= f32::EPSILON {
+ return 1.0;
+ }
+ ((self.elapsed_secs - self.morph_hold_secs()) / phase_secs).clamp(0.0, 1.0)
}
/// Returns the current Mobius morph progress for the active direction.
+ ///
+ /// The morph resumes from `start_morph` so interrupting a transition never
+ /// snaps the strip geometry.
pub fn morph_progress(&self) -> f32 {
match self.direction {
- MobiusTransitionDirection::Entering => self.enter_morph_progress(),
+ MobiusTransitionDirection::Entering => {
+ self.start_morph + (1.0 - self.start_morph) * self.morph_phase_progress()
+ }
MobiusTransitionDirection::Exiting => {
- 1.0 - ((self.elapsed_secs - Self::VIEW_RESET_SECS) / Self::MORPH_SECS)
- .clamp(0.0, 1.0)
+ self.start_morph * (1.0 - self.morph_phase_progress())
}
}
}
@@ -141,46 +303,49 @@ impl MobiusTransition {
}
}
+ /// Returns the eased camera interpolation factor for the active direction.
+ ///
+ /// Fresh enters keep `start == end` so the entering lerp is a no-op there;
+ /// it only moves the camera when an interrupted exit resumes entering.
+ fn camera_lerp_t(&self) -> f32 {
+ let phase_secs = match self.direction {
+ MobiusTransitionDirection::Entering => Self::ZOOM_OUT_SECS,
+ MobiusTransitionDirection::Exiting => Self::VIEW_RESET_SECS,
+ };
+ ease_in_out((self.elapsed_secs / phase_secs).clamp(0.0, 1.0))
+ }
+
/// Returns the current animated camera yaw.
pub fn current_yaw(&self) -> f32 {
- let t = match self.direction {
- MobiusTransitionDirection::Entering => 0.0,
- MobiusTransitionDirection::Exiting => {
- ease_in_out((self.elapsed_secs / Self::VIEW_RESET_SECS).clamp(0.0, 1.0))
- }
- };
- self.start_yaw + (self.end_yaw - self.start_yaw) * t
+ self.start_yaw + (self.end_yaw - self.start_yaw) * self.camera_lerp_t()
}
/// Returns the current animated camera pitch.
pub fn current_pitch(&self) -> f32 {
- let t = match self.direction {
- MobiusTransitionDirection::Entering => 0.0,
- MobiusTransitionDirection::Exiting => {
- ease_in_out((self.elapsed_secs / Self::VIEW_RESET_SECS).clamp(0.0, 1.0))
- }
- };
- self.start_pitch + (self.end_pitch - self.start_pitch) * t
+ self.start_pitch + (self.end_pitch - self.start_pitch) * self.camera_lerp_t()
}
- /// Returns the current animated camera pan offset.
- pub fn current_camera_offset(&self) -> Vec2 {
- let t = match self.direction {
- MobiusTransitionDirection::Entering => 0.0,
- MobiusTransitionDirection::Exiting => {
- ease_in_out((self.elapsed_secs / Self::VIEW_RESET_SECS).clamp(0.0, 1.0))
- }
- };
- self.start_camera_offset.lerp(self.end_camera_offset, t)
+ /// Returns the current animated camera roll.
+ pub fn current_roll(&self) -> f32 {
+ self.start_roll + (self.end_roll - self.start_roll) * self.camera_lerp_t()
+ }
+
+ /// Returns the current animated camera translation.
+ pub fn current_translation(&self) -> Vec3 {
+ self.start_translation
+ .lerp(self.end_translation, self.camera_lerp_t())
}
/// Returns whether the full transition has finished.
pub fn finished(&self) -> bool {
- self.elapsed_secs
- >= match self.direction {
- MobiusTransitionDirection::Entering => Self::ZOOM_OUT_SECS + Self::MORPH_SECS,
- MobiusTransitionDirection::Exiting => Self::VIEW_RESET_SECS + Self::MORPH_SECS,
- }
+ // Wait for both the camera phase (always the full hold window) and
+ // the morph, which may start immediately on a resume.
+ let camera_secs = match self.direction {
+ MobiusTransitionDirection::Entering => Self::ZOOM_OUT_SECS,
+ MobiusTransitionDirection::Exiting => Self::VIEW_RESET_SECS,
+ };
+ let morph_secs = self.morph_hold_secs() + self.morph_phase_secs();
+ self.elapsed_secs >= morph_secs.max(camera_secs)
}
}
@@ -191,18 +356,23 @@ impl Default for MobiusTransition {
elapsed_secs: 0.0,
direction: MobiusTransitionDirection::Entering,
source_mode: TerminalPresentationMode::Flat2d,
+ source_slot: None,
source_zoom: 1.0,
source_yaw: 0.0,
source_pitch: 0.0,
- source_camera_offset: Vec2::ZERO,
+ source_roll: 0.0,
+ source_translation: Vec3::ZERO,
+ start_morph: 0.0,
start_zoom: 0.0,
end_zoom: 0.0,
start_yaw: 0.0,
start_pitch: 0.0,
- start_camera_offset: Vec2::ZERO,
+ start_roll: 0.0,
+ start_translation: Vec3::ZERO,
end_yaw: 0.0,
end_pitch: 0.0,
- end_camera_offset: Vec2::ZERO,
+ end_roll: 0.0,
+ end_translation: Vec3::ZERO,
}
}
}
@@ -210,3 +380,260 @@ impl Default for MobiusTransition {
fn ease_in_out(t: f32) -> f32 {
t * t * (3.0 - 2.0 * t)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn exit_transition_preserves_the_minimum_protocol_zoom() {
+ let pose = TerminalCameraPose {
+ orthographic_scale: MIN_ORTHOGRAPHIC_SCALE,
+ ..TerminalCameraPose::default()
+ };
+ let mut transition = MobiusTransition::default();
+ transition.begin_enter(
+ 0,
+ &TerminalMobiusSource {
+ mode: TerminalPresentationMode::Plane3d,
+ pose,
+ },
+ &pose,
+ MobiusEnterZoomFloor::KeyboardTarget,
+ );
+ transition.begin_exit(0, &pose, 1.0);
+
+ assert_eq!(transition.end_zoom, MIN_ORTHOGRAPHIC_SCALE);
+ }
+
+ #[test]
+ fn protocol_enters_display_sub_unit_scales_exactly() {
+ let pose = TerminalCameraPose {
+ orthographic_scale: 0.4,
+ ..TerminalCameraPose::default()
+ };
+ let mut transition = MobiusTransition::default();
+ transition.begin_enter(
+ 0,
+ &TerminalMobiusSource {
+ mode: TerminalPresentationMode::Plane3d,
+ pose,
+ },
+ &pose,
+ MobiusEnterZoomFloor::ProtocolExact,
+ );
+
+ assert_eq!(transition.end_zoom, 0.4);
+ transition.begin_enter(
+ 0,
+ &TerminalMobiusSource {
+ mode: TerminalPresentationMode::Plane3d,
+ pose,
+ },
+ &pose,
+ MobiusEnterZoomFloor::KeyboardTarget,
+ );
+ assert_eq!(
+ transition.end_zoom,
+ MobiusTransition::TARGET_ZOOM_MULTIPLIER
+ );
+ }
+
+ #[test]
+ fn exiting_mid_enter_keeps_the_morph_and_zoom_continuous() {
+ let pose = TerminalCameraPose::default();
+ let mut transition = MobiusTransition::default();
+ transition.begin_enter(
+ 0,
+ &TerminalMobiusSource {
+ mode: TerminalPresentationMode::Plane3d,
+ pose,
+ },
+ &pose,
+ MobiusEnterZoomFloor::KeyboardTarget,
+ );
+ transition.elapsed_secs =
+ MobiusTransition::ZOOM_OUT_SECS + MobiusTransition::MORPH_SECS * 0.3;
+ let morph_before = transition.morph_progress();
+ let zoom_before = transition.current_zoom();
+ assert!((morph_before - 0.3).abs() < 1e-6);
+
+ transition.begin_exit(0, &pose, zoom_before);
+
+ assert!((transition.morph_progress() - morph_before).abs() < 1e-6);
+ assert_eq!(transition.current_zoom(), zoom_before);
+ transition.elapsed_secs = MobiusTransition::VIEW_RESET_SECS + MobiusTransition::MORPH_SECS;
+ assert_eq!(transition.morph_progress(), 0.0);
+ }
+
+ #[test]
+ fn entering_mid_exit_resumes_from_the_current_state() {
+ let pose = TerminalCameraPose::default();
+ let mut transition = MobiusTransition::default();
+ transition.begin_enter(
+ 0,
+ &TerminalMobiusSource {
+ mode: TerminalPresentationMode::Flat2d,
+ pose,
+ },
+ &pose,
+ MobiusEnterZoomFloor::KeyboardTarget,
+ );
+ transition.elapsed_secs = MobiusTransition::ZOOM_OUT_SECS + MobiusTransition::MORPH_SECS;
+ transition.begin_exit(0, &pose, transition.current_zoom());
+ transition.elapsed_secs =
+ MobiusTransition::VIEW_RESET_SECS + MobiusTransition::MORPH_SECS * 0.5;
+ let morph_before = transition.morph_progress();
+ assert!((morph_before - 0.5).abs() < 1e-6);
+
+ transition.begin_enter(
+ 0,
+ &TerminalMobiusSource {
+ mode: TerminalPresentationMode::Flat2d,
+ pose,
+ },
+ &pose,
+ MobiusEnterZoomFloor::KeyboardTarget,
+ );
+
+ assert!(matches!(
+ transition.direction,
+ MobiusTransitionDirection::Entering
+ ));
+ assert!((transition.morph_progress() - morph_before).abs() < 1e-6);
+ assert_eq!(transition.source_mode, TerminalPresentationMode::Flat2d);
+ transition.elapsed_secs = MobiusTransition::ZOOM_OUT_SECS + MobiusTransition::MORPH_SECS;
+ assert_eq!(transition.morph_progress(), 1.0);
+ }
+
+ #[test]
+ fn resumed_transitions_shrink_the_morph_phase_proportionally() {
+ let pose = TerminalCameraPose::default();
+ let mut transition = MobiusTransition::default();
+ transition.begin_enter(
+ 0,
+ &TerminalMobiusSource {
+ mode: TerminalPresentationMode::Plane3d,
+ pose,
+ },
+ &pose,
+ MobiusEnterZoomFloor::KeyboardTarget,
+ );
+ transition.elapsed_secs =
+ MobiusTransition::ZOOM_OUT_SECS + MobiusTransition::MORPH_SECS * 0.7;
+ transition.begin_exit(0, &pose, transition.current_zoom());
+
+ // The remaining 0.7 of morph unwinds in 0.7 * MORPH_SECS with no
+ // hold: velocity is continuous, so halfway through the shortened
+ // phase the morph has covered half the remaining distance.
+ transition.elapsed_secs = MobiusTransition::MORPH_SECS * 0.35;
+ assert!((transition.morph_progress() - 0.35).abs() < 1e-6);
+ assert!(!transition.finished());
+ transition.elapsed_secs = MobiusTransition::MORPH_SECS * 0.7;
+ assert_eq!(transition.morph_progress(), 0.0);
+ assert!(transition.finished());
+
+ // A resume with no remaining morph distance completes right after the
+ // camera hold phase instead of dividing by zero.
+ let mut degenerate = MobiusTransition::default();
+ degenerate.begin_exit(0, &pose, 1.0);
+ degenerate.elapsed_secs = MobiusTransition::VIEW_RESET_SECS + MobiusTransition::MORPH_SECS;
+ degenerate.begin_exit(0, &pose, 1.0);
+ assert_eq!(degenerate.morph_progress(), 0.0);
+ assert!(!degenerate.finished());
+ degenerate.elapsed_secs = MobiusTransition::VIEW_RESET_SECS;
+ assert!(degenerate.finished());
+ }
+
+ #[test]
+ fn repeated_identical_handoffs_do_not_restart_the_clock() {
+ let pose = TerminalCameraPose::default();
+ let source = TerminalMobiusSource {
+ mode: TerminalPresentationMode::Plane3d,
+ pose,
+ };
+ let mut transition = MobiusTransition::default();
+ transition.begin_enter(0, &source, &pose, MobiusEnterZoomFloor::ProtocolExact);
+ transition.elapsed_secs = 0.15;
+
+ // Rapid re-activations toward identical targets (e.g. alternating
+ // identically-posed Mobius slots) must not reset the clock, or the
+ // transition could be held open forever.
+ transition.prepare_source(1, source.mode, &source.pose);
+ transition.begin_enter(1, &source, &pose, MobiusEnterZoomFloor::ProtocolExact);
+
+ assert_eq!(transition.elapsed_secs, 0.15);
+ assert!(transition.source_is_for(1));
+ }
+
+ #[test]
+ fn resumed_morph_advances_during_the_camera_phase() {
+ let live_pose = TerminalCameraPose::default();
+ let other_pose = TerminalCameraPose {
+ yaw: 1.0,
+ ..TerminalCameraPose::default()
+ };
+ let source = TerminalMobiusSource {
+ mode: TerminalPresentationMode::Plane3d,
+ pose: live_pose,
+ };
+ let mut transition = MobiusTransition::default();
+ transition.begin_enter(0, &source, &live_pose, MobiusEnterZoomFloor::ProtocolExact);
+ transition.elapsed_secs =
+ MobiusTransition::ZOOM_OUT_SECS + MobiusTransition::MORPH_SECS * 0.4;
+
+ // A hand-off to a differently-posed slot restarts the camera lerp,
+ // but the morph keeps advancing immediately instead of freezing for
+ // the hold window.
+ transition.prepare_source(1, source.mode, &source.pose);
+ transition.begin_enter(1, &source, &other_pose, MobiusEnterZoomFloor::ProtocolExact);
+ assert!((transition.morph_progress() - 0.4).abs() < 1e-6);
+ transition.elapsed_secs = MobiusTransition::ZOOM_OUT_SECS / 2.0;
+ assert!(transition.morph_progress() > 0.4);
+ }
+
+ #[test]
+ fn handoff_resumes_continuously_from_an_exiting_transition() {
+ let pose = TerminalCameraPose::default();
+ let source = TerminalMobiusSource {
+ mode: TerminalPresentationMode::Plane3d,
+ pose,
+ };
+ let mut transition = MobiusTransition::default();
+ transition.begin_enter(0, &source, &pose, MobiusEnterZoomFloor::KeyboardTarget);
+ transition.elapsed_secs = MobiusTransition::ZOOM_OUT_SECS + MobiusTransition::MORPH_SECS;
+ transition.begin_exit(0, &pose, transition.current_zoom());
+ transition.elapsed_secs = MobiusTransition::MORPH_SECS * 0.5;
+ let morph_before = transition.morph_progress();
+ assert!(morph_before > 0.0 && morph_before < 1.0);
+
+ transition.prepare_source(1, source.mode, &source.pose);
+ transition.begin_enter(1, &source, &pose, MobiusEnterZoomFloor::ProtocolExact);
+
+ assert!(matches!(
+ transition.direction,
+ MobiusTransitionDirection::Entering
+ ));
+ assert!((transition.morph_progress() - morph_before).abs() < 1e-6);
+ assert!(transition.source_is_for(1));
+ }
+
+ #[test]
+ fn exit_interpolates_roll_back_to_the_source() {
+ let source_pose = TerminalCameraPose {
+ roll: 0.1,
+ ..TerminalCameraPose::default()
+ };
+ let mobius_pose = TerminalCameraPose {
+ roll: 0.5,
+ ..TerminalCameraPose::default()
+ };
+ let mut transition = MobiusTransition::default();
+ transition.prepare_source(0, TerminalPresentationMode::Plane3d, &source_pose);
+ transition.begin_exit(0, &mobius_pose, 1.0);
+
+ assert_eq!(transition.current_roll(), 0.5);
+ transition.elapsed_secs = MobiusTransition::VIEW_RESET_SECS;
+ assert!((transition.current_roll() - 0.1).abs() < 1e-6);
+ }
+}
diff --git a/src/scene/mod.rs b/src/scene/mod.rs
index 8452286..4da3a8a 100644
--- a/src/scene/mod.rs
+++ b/src/scene/mod.rs
@@ -2,7 +2,7 @@
mod mobius;
-pub use mobius::{MobiusTransition, MobiusTransitionDirection};
+pub use mobius::{MobiusEnterZoomFloor, MobiusTransition, MobiusTransitionDirection};
use bevy::asset::RenderAssetUsages;
use bevy::camera::ClearColorConfig;
@@ -16,12 +16,18 @@ use bevy::window::PrimaryWindow;
use bevy::camera::visibility::NoFrustumCulling;
+use crate::camera::{
+ MIN_ORTHOGRAPHIC_SCALE, TerminalCameraInteraction, TerminalCameraPreset, TerminalCameraSlots,
+};
use crate::config::AppConfig;
use crate::direct_render::{new_terminal_image, new_terminal_render_image};
use crate::present::{TerminalPresentMaterial, fullscreen_quad};
use crate::runtime::TerminalRuntime;
use crate::terminal::{TerminalLayout, TerminalSurface, render_scale_for_window};
+const TERMINAL_PERSPECTIVE_NEAR: f32 = 0.1;
+const TERMINAL_PERSPECTIVE_FAR: f32 = 10_000.0;
+
/// Marker for the 2D terminal sprite.
#[derive(Component)]
pub struct TerminalSprite;
@@ -34,9 +40,13 @@ pub struct TerminalPlane;
#[derive(Component)]
pub struct TerminalPlaneBack;
-/// Marker for the 3D presentation camera.
+/// Marker for the orthographic 3D presentation camera.
+#[derive(Component)]
+pub struct TerminalOrthographicCamera;
+
+/// Marker for the perspective 3D presentation camera.
#[derive(Component)]
-pub struct TerminalPlaneCamera;
+pub struct TerminalPerspectiveCamera;
/// Handles for terminal plane meshes.
#[derive(Resource)]
@@ -71,12 +81,15 @@ pub struct TerminalViewport {
}
/// Terminal presentation mode.
-#[derive(Resource, Clone, Copy, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum TerminalPresentationMode {
/// Flat 2D presentation.
+ #[default]
Flat2d,
/// Warped 3D presentation.
Plane3d,
+ /// Perspective 3D presentation.
+ Perspective3d,
/// Mobius-strip 3D presentation.
Mobius3d,
}
@@ -93,71 +106,6 @@ impl TerminalPresentationMode {
}
}
-/// Active terminal presentation.
-#[derive(Resource)]
-pub struct TerminalPresentation {
- /// Current presentation mode.
- pub mode: TerminalPresentationMode,
-}
-
-impl TerminalPresentation {
- /// Toggles between the flat and warped 3D terminal views.
- pub fn toggle_plane_mode(&mut self) {
- self.mode = match self.mode {
- TerminalPresentationMode::Flat2d => TerminalPresentationMode::Plane3d,
- TerminalPresentationMode::Plane3d | TerminalPresentationMode::Mobius3d => {
- TerminalPresentationMode::Flat2d
- }
- };
- }
-
- /// Toggles the Mobius-strip terminal view.
- pub fn toggle_mobius_mode(&mut self) {
- self.mode = match self.mode {
- TerminalPresentationMode::Mobius3d => TerminalPresentationMode::Flat2d,
- TerminalPresentationMode::Flat2d | TerminalPresentationMode::Plane3d => {
- TerminalPresentationMode::Mobius3d
- }
- };
- }
-}
-
-/// Camera state for 3D presentation.
-#[derive(Resource)]
-pub struct TerminalPlaneView {
- /// Camera yaw.
- pub yaw: f32,
- /// Camera pitch.
- pub pitch: f32,
- /// Camera zoom factor.
- pub zoom: f32,
- /// Camera pan offset.
- pub camera_offset: Vec2,
- /// Indicates drag rotation.
- pub rotating: bool,
- /// Indicates drag panning.
- pub panning: bool,
- /// Last rotation cursor position.
- pub last_rotate_cursor: Option,
- /// Last pan cursor position.
- pub last_pan_cursor: Option,
-}
-
-impl Default for TerminalPlaneView {
- fn default() -> Self {
- Self {
- yaw: 0.18,
- pitch: 0.08,
- zoom: 1.0,
- camera_offset: Vec2::ZERO,
- rotating: false,
- panning: false,
- last_rotate_cursor: None,
- last_pan_cursor: None,
- }
- }
-}
-
/// Model loading state.
#[derive(Resource)]
pub struct ModelLoadState {
@@ -176,8 +124,48 @@ type PlaneMaterialQuery<'w, 's> =
type PlaneTransformQuery<'w, 's> = Query<'w, 's, &'static mut Transform, With>;
type PlaneBackTransformQuery<'w, 's> =
Query<'w, 's, &'static mut Transform, With>;
-type PlaneCameraQuery<'w, 's> =
- Query<'w, 's, (&'static mut Projection, &'static mut Transform), With>;
+type OrthographicCameraQuery<'w, 's> = Query<
+ 'w,
+ 's,
+ (
+ &'static mut Projection,
+ &'static mut Transform,
+ &'static mut Camera,
+ ),
+ (
+ With,
+ Without,
+ Without,
+ Without,
+ Without,
+ ),
+>;
+type PerspectiveCameraQuery<'w, 's> = Query<
+ 'w,
+ 's,
+ (
+ &'static mut Projection,
+ &'static mut Transform,
+ &'static mut Camera,
+ ),
+ (
+ With,
+ Without,
+ Without,
+ Without,
+ Without,
+ ),
+>;
+type FlatCameraQuery<'w, 's> = Query<
+ 'w,
+ 's,
+ &'static mut Camera,
+ (
+ With,
+ Without,
+ Without,
+ ),
+>;
pub(crate) type TerminalPlaneLayoutQuery<'w, 's> =
Query<'w, 's, &'static mut Transform, (With, Without)>;
pub(crate) type TerminalPlaneBackLayoutQuery<'w, 's> = Query<
@@ -204,17 +192,11 @@ pub(crate) struct PresentationParams<'w, 's> {
>,
plane_materials: PlaneMaterialQuery<'w, 's>,
materials: ResMut<'w, Assets>,
- plane_transforms: ParamSet<
- 'w,
- 's,
- (
- PlaneTransformQuery<'w, 's>,
- PlaneBackTransformQuery<'w, 's>,
- PlaneCameraQuery<'w, 's>,
- ),
- >,
- camera_2d: Query<'w, 's, &'static mut Camera, (With, Without)>,
- camera_3d: Query<'w, 's, &'static mut Camera, (With, Without)>,
+ plane_transforms:
+ ParamSet<'w, 's, (PlaneTransformQuery<'w, 's>, PlaneBackTransformQuery<'w, 's>)>,
+ camera_2d: FlatCameraQuery<'w, 's>,
+ orthographic_camera: OrthographicCameraQuery<'w, 's>,
+ perspective_camera: PerspectiveCameraQuery<'w, 's>,
}
#[derive(SystemParam)]
@@ -268,7 +250,23 @@ pub(crate) fn setup_scene(mut params: SetupSceneParams) {
Msaa::Off,
));
commands.spawn((
- TerminalPlaneCamera,
+ TerminalPerspectiveCamera,
+ Camera3d::default(),
+ Camera {
+ order: 1,
+ clear_color: ClearColorConfig::None,
+ ..default()
+ },
+ Projection::Perspective(PerspectiveProjection {
+ near: TERMINAL_PERSPECTIVE_NEAR,
+ far: TERMINAL_PERSPECTIVE_FAR,
+ ..PerspectiveProjection::default()
+ }),
+ Transform::from_xyz(0.0, 0.0, 800.0).looking_at(Vec3::ZERO, Vec3::Y),
+ Msaa::Off,
+ ));
+ commands.spawn((
+ TerminalOrthographicCamera,
Camera3d::default(),
Camera {
order: 1,
@@ -354,6 +352,7 @@ pub(crate) fn setup_scene(mut params: SetupSceneParams) {
})),
Transform::from_scale(layout.logical_size.extend(1.0)),
Visibility::Hidden,
+ NoFrustumCulling,
));
commands.spawn((
@@ -372,6 +371,7 @@ pub(crate) fn setup_scene(mut params: SetupSceneParams) {
scale: layout.logical_size.extend(1.0),
},
Visibility::Hidden,
+ NoFrustumCulling,
));
commands.spawn((
@@ -400,10 +400,8 @@ pub(crate) fn setup_scene(mut params: SetupSceneParams) {
},
Transform::from_xyz(-280.0, -120.0, 700.0),
));
- commands.insert_resource(TerminalPresentation {
- mode: TerminalPresentationMode::Flat2d,
- });
- commands.insert_resource(TerminalPlaneView::default());
+ commands.insert_resource(TerminalCameraSlots::default());
+ commands.insert_resource(TerminalCameraInteraction::default());
commands.insert_resource(MobiusTransition::default());
commands.insert_resource(ModelLoadState {
loaded: false,
@@ -446,37 +444,121 @@ fn create_terminal_image(width: u32, height: u32, fill: [u8; 4]) -> Image {
image
}
-/// Applies the active terminal presentation mode.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum TerminalClearOwner {
+ Flat,
+ Orthographic,
+ Perspective,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+struct TerminalCameraActivation {
+ flat: bool,
+ orthographic: bool,
+ perspective: bool,
+ clear_owner: TerminalClearOwner,
+}
+
+fn camera_activation(mode: TerminalPresentationMode) -> TerminalCameraActivation {
+ match mode {
+ TerminalPresentationMode::Flat2d => TerminalCameraActivation {
+ flat: true,
+ orthographic: true,
+ perspective: false,
+ clear_owner: TerminalClearOwner::Flat,
+ },
+ TerminalPresentationMode::Plane3d | TerminalPresentationMode::Mobius3d => {
+ TerminalCameraActivation {
+ flat: false,
+ orthographic: true,
+ perspective: false,
+ clear_owner: TerminalClearOwner::Orthographic,
+ }
+ }
+ TerminalPresentationMode::Perspective3d => TerminalCameraActivation {
+ flat: false,
+ orthographic: false,
+ perspective: true,
+ clear_owner: TerminalClearOwner::Perspective,
+ },
+ }
+}
+
+fn set_camera_state(mut camera: Mut, active: bool, owns_clear: bool) {
+ if camera.is_active != active {
+ camera.is_active = active;
+ }
+ let clear_matches = if owns_clear {
+ matches!(camera.clear_color, ClearColorConfig::Default)
+ } else {
+ matches!(camera.clear_color, ClearColorConfig::None)
+ };
+ if !clear_matches {
+ camera.clear_color = if owns_clear {
+ ClearColorConfig::Default
+ } else {
+ ClearColorConfig::None
+ };
+ }
+}
+
+/// Composes the orbit rotation applied to the 3D terminal cameras.
+///
+/// Yaw must stay outermost and pitch inside it (YXZ order) so this matches the
+/// inverse of the `Rx(pitch) * Ry(yaw)` plane rotation the fixed camera
+/// replaced; with pitch outermost, pitch input degenerates into roll as yaw
+/// approaches 90 degrees. Roll is innermost so it stays on the view axis.
+fn camera_orbit_rotation(yaw: f32, pitch: f32, roll: f32) -> Quat {
+ Quat::from_euler(EulerRot::YXZ, -yaw, -pitch, roll)
+}
+
+/// Synchronizes the active camera preset to the presentation entities.
pub(crate) fn apply_terminal_presentation(
- presentation: Res,
- plane_view: Res,
+ camera_slots: Res,
mobius_transition: Res,
+ mut last_active_preset: Local>,
mut params: PresentationParams,
) {
+ let current = (camera_slots.active_slot(), *camera_slots.active());
+ if last_active_preset.as_ref() == Some(¤t) && !mobius_transition.is_changed() {
+ return;
+ }
+ *last_active_preset = Some(current);
+
let PresentationParams {
visibility_queries,
plane_materials,
materials,
plane_transforms,
camera_2d,
- camera_3d,
+ orthographic_camera,
+ perspective_camera,
} = &mut params;
- let is_3d = presentation.mode.is_3d();
- let is_mobius = presentation.mode.is_mobius();
+ let preset = camera_slots.active();
+ let mode = preset.mode;
+ let pose = preset.pose;
+ let activation = camera_activation(mode);
+ let is_3d = mode.is_3d();
+ let is_mobius = mode.is_mobius();
let yaw = if is_mobius && mobius_transition.active {
mobius_transition.current_yaw()
} else {
- plane_view.yaw
+ pose.yaw
};
let pitch = if is_mobius && mobius_transition.active {
mobius_transition.current_pitch()
} else {
- plane_view.pitch
+ pose.pitch
+ };
+ let roll = if is_mobius && mobius_transition.active {
+ mobius_transition.current_roll()
+ } else {
+ pose.roll
};
- let camera_offset = if is_mobius && mobius_transition.active {
- mobius_transition.current_camera_offset()
+ let translation = if is_mobius && mobius_transition.active {
+ mobius_transition.current_translation()
} else {
- plane_view.camera_offset
+ pose.translation
};
let sprite_visibility = if is_3d {
Visibility::Hidden
@@ -490,21 +572,28 @@ pub(crate) fn apply_terminal_presentation(
};
for mut visibility in &mut visibility_queries.p0() {
- *visibility = sprite_visibility;
+ if *visibility != sprite_visibility {
+ *visibility = sprite_visibility;
+ }
}
for mut visibility in &mut visibility_queries.p1() {
- *visibility = plane_visibility;
+ if *visibility != plane_visibility {
+ *visibility = plane_visibility;
+ }
}
for mut visibility in &mut visibility_queries.p2() {
// A Mobius strip is one continuous ribbon, so the separate back sheet model does not map
// cleanly. Render the front material double-sided instead.
- *visibility = if is_3d && !is_mobius {
+ let target = if is_3d && !is_mobius {
Visibility::Visible
} else {
Visibility::Hidden
};
+ if *visibility != target {
+ *visibility = target;
+ }
}
if let Ok(front_material) = plane_materials.single() {
@@ -519,65 +608,103 @@ pub(crate) fn apply_terminal_presentation(
}
}
- // The 2D camera only contributes in flat mode; the 3D camera stays active
- // everywhere because the cursor model and RGP objects render through it
- // even in 2D mode. Whichever camera renders first owns the screen clear.
- for mut camera in camera_2d.iter_mut() {
- let active = !is_3d;
- if camera.is_active != active {
- camera.is_active = active;
- }
- }
- // This system only runs when presentation state changes, so assigning
- // unconditionally does not churn change detection every frame.
- for mut camera in camera_3d.iter_mut() {
- camera.clear_color = if is_3d {
- ClearColorConfig::Default
- } else {
- ClearColorConfig::None
- };
+ for camera in camera_2d.iter_mut() {
+ set_camera_state(
+ camera,
+ activation.flat,
+ activation.clear_owner == TerminalClearOwner::Flat,
+ );
}
-
for mut transform in &mut plane_transforms.p0() {
- transform.rotation = if is_3d {
- Quat::from_euler(EulerRot::XYZ, pitch, yaw, 0.0)
- } else {
- Quat::IDENTITY
- };
+ if transform.rotation != Quat::IDENTITY {
+ transform.rotation = Quat::IDENTITY;
+ }
}
for mut transform in &mut plane_transforms.p1() {
- if is_3d {
- transform.rotation =
- Quat::from_euler(EulerRot::XYZ, pitch, yaw + std::f32::consts::PI, 0.0);
- transform.translation = if is_mobius {
- Vec3::ZERO
- } else {
- Vec3::new(0.0, 0.0, -2.0)
- };
- } else {
- transform.rotation = Quat::IDENTITY;
- transform.translation = Vec3::new(0.0, 0.0, -2.0);
+ let rotation = Quat::from_rotation_y(std::f32::consts::PI);
+ let translation = Vec3::new(0.0, 0.0, -2.0);
+ if transform.rotation != rotation {
+ transform.rotation = rotation;
+ }
+ if transform.translation != translation {
+ transform.translation = translation;
}
}
- for (mut projection, mut transform) in &mut plane_transforms.p2() {
- if let Projection::Orthographic(ortho) = projection.as_mut() {
- let zoom = if is_mobius && mobius_transition.active {
- mobius_transition.current_zoom()
- } else {
- plane_view.zoom
- };
- ortho.scale = if is_3d { zoom } else { 1.0 };
- }
+ let camera_translation = if is_3d { translation } else { Vec3::ZERO };
+ let camera_distance = (800.0 + camera_translation.z).max(0.1);
+ let camera_target = Vec3::new(camera_translation.x, camera_translation.y, 0.0);
+ let camera_rotation = if is_3d {
+ camera_orbit_rotation(yaw, pitch, roll)
+ } else {
+ Quat::IDENTITY
+ };
+ let camera_position = camera_target + camera_rotation * Vec3::Z * camera_distance;
+ let camera_transform = Transform {
+ translation: camera_position,
+ rotation: camera_rotation,
+ ..default()
+ };
- let offset = if is_3d {
- camera_offset.extend(0.0)
+ for (mut projection, mut transform, camera) in orthographic_camera.iter_mut() {
+ set_camera_state(
+ camera,
+ activation.orthographic,
+ activation.clear_owner == TerminalClearOwner::Orthographic,
+ );
+ if !activation.orthographic {
+ continue;
+ }
+ let scale = if is_3d {
+ if is_mobius && mobius_transition.active {
+ mobius_transition.current_zoom().max(MIN_ORTHOGRAPHIC_SCALE)
+ } else {
+ pose.resolved_orthographic_scale()
+ }
} else {
- Vec3::ZERO
+ 1.0
};
- transform.translation = Vec3::new(0.0, 0.0, 800.0) + offset;
- transform.look_at(offset, Vec3::Y);
+ let projection_needs_update = matches!(
+ &*projection,
+ Projection::Orthographic(orthographic) if orthographic.scale != scale
+ );
+ if projection_needs_update
+ && let Projection::Orthographic(orthographic) = projection.as_mut()
+ {
+ orthographic.scale = scale;
+ }
+ if *transform != camera_transform {
+ *transform = camera_transform;
+ }
+ }
+
+ for (mut projection, mut transform, camera) in perspective_camera.iter_mut() {
+ set_camera_state(
+ camera,
+ activation.perspective,
+ activation.clear_owner == TerminalClearOwner::Perspective,
+ );
+ if !activation.perspective {
+ continue;
+ }
+ let fov = pose.resolved_perspective_fov();
+ let projection_needs_update = matches!(
+ &*projection,
+ Projection::Perspective(perspective)
+ if perspective.fov != fov
+ || perspective.near != TERMINAL_PERSPECTIVE_NEAR
+ || perspective.far != TERMINAL_PERSPECTIVE_FAR
+ );
+ if projection_needs_update && let Projection::Perspective(perspective) = projection.as_mut()
+ {
+ perspective.fov = fov;
+ perspective.near = TERMINAL_PERSPECTIVE_NEAR;
+ perspective.far = TERMINAL_PERSPECTIVE_FAR;
+ }
+ if *transform != camera_transform {
+ *transform = camera_transform;
+ }
}
}
@@ -624,3 +751,241 @@ fn terminal_plane_mesh(x_segments: u32, y_segments: u32) -> Mesh {
.with_inserted_attribute(Mesh::ATTRIBUTE_UV_0, uvs)
.with_inserted_indices(Indices::U32(indices))
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[derive(Resource, Default)]
+ struct ChangedCameraCount(usize);
+
+ fn count_changed_cameras(
+ cameras: Query<(), Changed>,
+ mut count: ResMut,
+ ) {
+ count.0 = cameras.iter().count();
+ }
+
+ fn presentation_app() -> (App, Entity, Entity, Entity) {
+ let mut app = App::new();
+ app.init_resource::()
+ .init_resource::()
+ .init_resource::>()
+ .init_resource::();
+ let material = app
+ .world_mut()
+ .resource_mut::>()
+ .add(StandardMaterial::default());
+ app.world_mut().spawn((TerminalSprite, Visibility::Visible));
+ app.world_mut().spawn((
+ TerminalPlane,
+ MeshMaterial3d(material),
+ Transform::default(),
+ Visibility::Hidden,
+ ));
+ app.world_mut()
+ .spawn((TerminalPlaneBack, Transform::default(), Visibility::Hidden));
+ let flat = app.world_mut().spawn((Camera2d, Camera::default())).id();
+ let orthographic = app
+ .world_mut()
+ .spawn((
+ TerminalOrthographicCamera,
+ Camera::default(),
+ Projection::Orthographic(OrthographicProjection::default_3d()),
+ Transform::default(),
+ ))
+ .id();
+ let perspective = app
+ .world_mut()
+ .spawn((
+ TerminalPerspectiveCamera,
+ Camera::default(),
+ Projection::Perspective(PerspectiveProjection::default()),
+ Transform::default(),
+ ))
+ .id();
+ app.add_systems(
+ Update,
+ (apply_terminal_presentation, count_changed_cameras).chain(),
+ );
+ (app, flat, orthographic, perspective)
+ }
+
+ #[test]
+ fn camera_activation_matrix_has_one_clear_owner() {
+ let cases = [
+ (
+ TerminalPresentationMode::Flat2d,
+ TerminalCameraActivation {
+ flat: true,
+ orthographic: true,
+ perspective: false,
+ clear_owner: TerminalClearOwner::Flat,
+ },
+ ),
+ (
+ TerminalPresentationMode::Plane3d,
+ TerminalCameraActivation {
+ flat: false,
+ orthographic: true,
+ perspective: false,
+ clear_owner: TerminalClearOwner::Orthographic,
+ },
+ ),
+ (
+ TerminalPresentationMode::Mobius3d,
+ TerminalCameraActivation {
+ flat: false,
+ orthographic: true,
+ perspective: false,
+ clear_owner: TerminalClearOwner::Orthographic,
+ },
+ ),
+ (
+ TerminalPresentationMode::Perspective3d,
+ TerminalCameraActivation {
+ flat: false,
+ orthographic: false,
+ perspective: true,
+ clear_owner: TerminalClearOwner::Perspective,
+ },
+ ),
+ ];
+
+ for (mode, expected) in cases {
+ assert_eq!(camera_activation(mode), expected);
+ let active_clear_owner = match expected.clear_owner {
+ TerminalClearOwner::Flat => expected.flat,
+ TerminalClearOwner::Orthographic => expected.orthographic,
+ TerminalClearOwner::Perspective => expected.perspective,
+ };
+ assert!(active_clear_owner);
+ }
+ }
+
+ #[test]
+ fn perspective_depth_range_is_valid() {
+ let projection = PerspectiveProjection {
+ near: TERMINAL_PERSPECTIVE_NEAR,
+ far: TERMINAL_PERSPECTIVE_FAR,
+ ..PerspectiveProjection::default()
+ };
+ assert!(projection.near.is_finite());
+ assert!(projection.near > 0.0);
+ assert!(projection.far.is_finite());
+ assert!(projection.far > projection.near);
+ }
+
+ #[test]
+ fn presentation_system_applies_activation_and_clear_ownership() {
+ let (mut app, flat, orthographic, perspective) = presentation_app();
+ let cases = [
+ (TerminalPresentationMode::Flat2d, [true, true, false]),
+ (TerminalPresentationMode::Plane3d, [false, true, false]),
+ (TerminalPresentationMode::Mobius3d, [false, true, false]),
+ (
+ TerminalPresentationMode::Perspective3d,
+ [false, false, true],
+ ),
+ ];
+
+ for (mode, expected_active) in cases {
+ app.world_mut()
+ .resource_mut::()
+ .active_mut()
+ .mode = mode;
+ app.update();
+
+ let cameras = [flat, orthographic, perspective].map(|entity| {
+ app.world()
+ .entity(entity)
+ .get::()
+ .expect("camera entity")
+ .clone()
+ });
+ assert_eq!(
+ cameras.each_ref().map(|camera| camera.is_active),
+ expected_active,
+ "wrong activation matrix for {mode:?}"
+ );
+ assert_eq!(
+ cameras
+ .iter()
+ .filter(|camera| matches!(camera.clear_color, ClearColorConfig::Default))
+ .count(),
+ 1,
+ "wrong clear ownership for {mode:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn orthographic_zoom_out_does_not_collapse_perspective_projection() {
+ let (mut app, _, _, perspective) = presentation_app();
+ {
+ let mut slots = app.world_mut().resource_mut::();
+ slots.active_mut().mode = TerminalPresentationMode::Plane3d;
+ slots.active_mut().pose.orthographic_scale = 4.0;
+ }
+ app.update();
+ app.world_mut()
+ .resource_mut::()
+ .active_mut()
+ .mode = TerminalPresentationMode::Perspective3d;
+ app.update();
+
+ let projection = app
+ .world()
+ .entity(perspective)
+ .get::()
+ .expect("perspective projection");
+ let Projection::Perspective(projection) = projection else {
+ panic!("expected perspective projection");
+ };
+ assert_eq!(projection.fov, 1.0);
+ assert!(projection.fov < std::f32::consts::FRAC_PI_2);
+ }
+
+ #[test]
+ fn orbit_rotation_keeps_pitch_and_yaw_independent() {
+ let pitch = 0.5_f32;
+ for yaw in [
+ 0.0_f32,
+ std::f32::consts::FRAC_PI_2,
+ 2.0,
+ std::f32::consts::PI,
+ ] {
+ let rotation = camera_orbit_rotation(yaw, pitch, 0.0);
+ let offset = rotation * Vec3::Z;
+ assert!(
+ (offset.y - pitch.sin()).abs() < 1e-6,
+ "pitch must tilt the orbit at yaw {yaw}"
+ );
+ // The two compositions are analytically identical, but
+ // Quat::angle_between is ill-conditioned near zero (acos of a
+ // dot product near 1.0 amplifies f32 rounding into ~1e-3 rad),
+ // so this pins the composition order, not bit-exact equality.
+ let plane_equivalent = Quat::from_euler(EulerRot::XYZ, pitch, yaw, 0.0).inverse();
+ assert!(
+ rotation.angle_between(plane_equivalent) < 5e-3,
+ "orbit must invert the legacy plane rotation at yaw {yaw}"
+ );
+ }
+ }
+
+ #[test]
+ fn pose_only_updates_do_not_mark_camera_components_changed() {
+ let (mut app, _, _, _) = presentation_app();
+ app.update();
+ app.world_mut().clear_trackers();
+ app.world_mut()
+ .resource_mut::()
+ .active_mut()
+ .pose
+ .yaw += 0.25;
+
+ app.update();
+
+ assert_eq!(app.world().resource::().0, 0);
+ }
+}
diff --git a/src/systems.rs b/src/systems.rs
index 6d25dc8..23d0bfc 100644
--- a/src/systems.rs
+++ b/src/systems.rs
@@ -2,19 +2,19 @@
//!
//! These systems are scheduled from [`crate::plugin::TerminalPlugin`] in a mostly linear flow:
//!
-//! - [`pump_pty_output`]
-//! - [`crate::keyboard::handle_keyboard_input`]
-//! - [`crate::mouse::handle_mouse_input`]
-//! - [`handle_window_resize`]
-//! - [`crate::scene::apply_terminal_presentation`]
-//! - [`apply_inline_objects`]
-//! - [`render_terminal_widget`]
-//! - [`sync_inline_objects`]
-//! - [`animate_inline_kitty_planes`]
-//! - [`sync_rgp_objects`]
-//! - [`apply_instance_brightness`]
-//! - [`animate_terminal_plane_warp`]
-//! - [`sync_asset_to_terminal_cursor`]
+//! - `pump_pty_output`
+//! - `keyboard::handle_keyboard_input`
+//! - `mouse::handle_mouse_input`
+//! - `handle_window_resize`
+//! - `scene::apply_terminal_presentation`
+//! - `apply_inline_objects`
+//! - `render_terminal_widget`
+//! - `sync_inline_objects`
+//! - `animate_inline_kitty_planes`
+//! - `sync_rgp_objects`
+//! - `apply_instance_brightness`
+//! - `animate_terminal_plane_warp`
+//! - `sync_asset_to_terminal_cursor`
//!
//! The redraw path updates the terminal texture and presentation state first, then the inline
//! object systems rebuild or reposition scene entities that depend on the terminal grid.
@@ -22,6 +22,9 @@
use std::collections::HashMap;
use std::sync::mpsc::TryRecvError;
+use crate::camera::{
+ MIN_ORTHOGRAPHIC_SCALE, TerminalCameraSlots, TerminalCameraUpdate, TerminalMobiusSource,
+};
use crate::config::{AppConfig, CURSOR_DEPTH};
use crate::direct_render::DirectTerminalSceneExchange;
use crate::inline::{
@@ -36,15 +39,15 @@ use crate::rendering::{sync_plane_texture, sync_terminal_debug_image};
use crate::runtime::TerminalRuntime;
use crate::scene::{
MobiusTransition, ModelLoadState, TerminalPlane, TerminalPlaneBack,
- TerminalPlaneBackLayoutQuery, TerminalPlaneLayoutQuery, TerminalPlaneMeshes, TerminalPlaneView,
- TerminalPlaneWarp, TerminalPresentation, TerminalPresentationMode, TerminalViewport,
- sync_terminal_layout,
+ TerminalPlaneBackLayoutQuery, TerminalPlaneLayoutQuery, TerminalPlaneMeshes, TerminalPlaneWarp,
+ TerminalPresentationMode, TerminalViewport, sync_terminal_layout,
};
use crate::terminal::{
TerminalRedrawState, TerminalSurface, TerminalWidget, render_scale_for_window,
};
use bevy::app::AppExit;
use bevy::asset::AssetMut;
+use bevy::camera::visibility::NoFrustumCulling;
use bevy::ecs::message::{MessageReader, MessageWriter};
use bevy::ecs::system::SystemParam;
use bevy::gltf::GltfAssetLabel;
@@ -70,6 +73,7 @@ struct InlineLayout {
struct KittyRenderContext<'a> {
mode: TerminalPresentationMode,
+ mobius_progress: f32,
warp_amount: f32,
elapsed_secs: f32,
materials: &'a mut Assets,
@@ -146,7 +150,7 @@ pub(crate) fn shutdown_terminal_runtime_on_exit(
/// Pumps PTY output into the terminal parser.
///
-/// This runs early in the update schedule, before [`render_terminal_widget`]. It drains PTY output
+/// This runs early in the update schedule, before `render_terminal_widget`. It drains PTY output
/// from [`TerminalRuntime`], feeds it through [`TerminalInlineObjects::consume_pty_output`] and
/// requests a redraw through [`TerminalRedrawState`] when terminal state changed.
///
@@ -155,6 +159,7 @@ pub(crate) fn shutdown_terminal_runtime_on_exit(
pub fn pump_pty_output(
mut runtime: ResMut,
mut inline_objects: ResMut,
+ mut camera_update_writer: MessageWriter,
mut app_exit: MessageWriter,
mut redraw: ResMut,
) {
@@ -164,6 +169,7 @@ pub fn pump_pty_output(
};
let mut processed_output = false;
+ let mut camera_updates = Vec::new();
loop {
match runtime.try_recv() {
Ok(chunk) => {
@@ -174,7 +180,15 @@ pub fn pump_pty_output(
} else {
None
};
- let mut replies = inline_objects.consume_pty_output(&chunk, &mut runtime.parser);
+ let mut replies = inline_objects.consume_pty_output(
+ &chunk,
+ &mut runtime.parser,
+ &mut camera_updates,
+ &mut processed_output,
+ );
+ for update in camera_updates.drain(..) {
+ camera_update_writer.write(update);
+ }
replies.extend(runtime.parser.callbacks_mut().take_replies());
for reply in replies {
runtime.write_input(&reply);
@@ -185,7 +199,6 @@ pub fn pump_pty_output(
inline_objects.apply_scroll(scrolled);
}
inline_objects.refresh_placeholder_anchors(runtime.parser.screen());
- processed_output = true;
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
@@ -287,11 +300,11 @@ pub(crate) fn handle_window_resize(
/// Applies inline object visibility for the current presentation mode.
///
-/// This runs after [`crate::scene::apply_terminal_presentation`] and only flips scene visibility.
+/// This runs after `scene::apply_terminal_presentation` and only flips scene visibility.
/// [`TerminalInlineObjectSprite`] entities are shown in [`TerminalPresentationMode::Flat2d`], while
/// [`TerminalInlineObjectPlane`] entities are shown in the 3D presentation modes.
pub fn apply_inline_objects(
- presentation: Res,
+ camera_slots: Res,
mut sprite_query: Query<&mut Visibility, With>,
mut plane_query: Query<
&mut Visibility,
@@ -301,24 +314,26 @@ pub fn apply_inline_objects(
),
>,
) {
- let sprite_visibility = match presentation.mode {
- TerminalPresentationMode::Flat2d => Visibility::Visible,
- TerminalPresentationMode::Plane3d | TerminalPresentationMode::Mobius3d => {
- Visibility::Hidden
- }
+ let sprite_visibility = if camera_slots.active().mode.is_3d() {
+ Visibility::Hidden
+ } else {
+ Visibility::Visible
};
- let plane_visibility = match presentation.mode {
- TerminalPresentationMode::Flat2d => Visibility::Hidden,
- TerminalPresentationMode::Plane3d | TerminalPresentationMode::Mobius3d => {
- Visibility::Visible
- }
+ let plane_visibility = if camera_slots.active().mode.is_3d() {
+ Visibility::Visible
+ } else {
+ Visibility::Hidden
};
for mut visibility in &mut sprite_query {
- *visibility = sprite_visibility;
+ if *visibility != sprite_visibility {
+ *visibility = sprite_visibility;
+ }
}
for mut visibility in &mut plane_query {
- *visibility = plane_visibility;
+ if *visibility != plane_visibility {
+ *visibility = plane_visibility;
+ }
}
}
@@ -408,7 +423,7 @@ pub(crate) fn render_terminal_widget(mut params: RenderWidgetParams) {
pub(crate) struct SyncMaterialsParams<'w, 's> {
runtime: Res<'w, TerminalRuntime>,
terminal: Res<'w, TerminalSurface>,
- presentation: Res<'w, TerminalPresentation>,
+ camera_slots: Res<'w, TerminalCameraSlots>,
images: ResMut<'w, Assets>,
materials: ResMut<'w, Assets>,
plane_materials: Query<'w, 's, &'static MeshMaterial3d, With>,
@@ -424,7 +439,7 @@ pub(crate) fn sync_terminal_materials(mut params: SyncMaterialsParams) {
let SyncMaterialsParams {
runtime,
terminal,
- presentation,
+ camera_slots,
images,
materials,
plane_materials,
@@ -451,10 +466,7 @@ pub(crate) fn sync_terminal_materials(mut params: SyncMaterialsParams) {
}
}
- let in_3d = matches!(
- presentation.mode,
- TerminalPresentationMode::Plane3d | TerminalPresentationMode::Mobius3d
- );
+ let in_3d = camera_slots.active().mode.is_3d();
if in_3d {
sync_terminal_debug_image(terminal, images, runtime.parser.screen());
}
@@ -530,7 +542,8 @@ pub(crate) struct SyncInlineParams<'w, 's> {
inline_objects: ResMut<'w, TerminalInlineObjects>,
terminal: Res<'w, TerminalSurface>,
viewport: Res<'w, TerminalViewport>,
- presentation: Res<'w, TerminalPresentation>,
+ camera_slots: Res<'w, TerminalCameraSlots>,
+ mobius_transition: Res<'w, MobiusTransition>,
plane_warp: Res<'w, TerminalPlaneWarp>,
time: Res<'w, Time>,
plane_query: Query<'w, 's, (Entity, &'static Transform), With>,
@@ -558,7 +571,8 @@ pub(crate) fn sync_inline_objects(mut params: SyncInlineParams) {
inline_objects,
terminal,
viewport,
- presentation,
+ camera_slots,
+ mobius_transition,
plane_warp,
time,
plane_query,
@@ -615,7 +629,11 @@ pub(crate) fn sync_inline_objects(mut params: SyncInlineParams) {
match object {
InlineObject::KittyImage(object) => {
let mut ctx = KittyRenderContext {
- mode: presentation.mode,
+ mode: camera_slots.active().mode,
+ mobius_progress: active_mobius_progress(
+ camera_slots.active().mode,
+ mobius_transition,
+ ),
warp_amount: plane_warp.amount,
elapsed_secs,
materials,
@@ -707,24 +725,16 @@ fn sync_kitty_inline_image(
TerminalInlineObjectSprite,
sprite,
Transform::from_translation(Vec3::new(layout.center_x, layout.center_y, 5.0)),
- match ctx.mode {
- TerminalPresentationMode::Flat2d => Visibility::Visible,
- TerminalPresentationMode::Plane3d | TerminalPresentationMode::Mobius3d => {
- Visibility::Hidden
- }
+ if ctx.mode.is_3d() {
+ Visibility::Hidden
+ } else {
+ Visibility::Visible
},
));
let plane_layout = inline_kitty_plane_layout(layout);
- let (mesh_handle, material_handle) = ensure_kitty_plane_assets(
- object,
- &plane_layout,
- &image_handle,
- ctx.warp_amount,
- ctx.elapsed_secs,
- ctx.materials,
- ctx.meshes,
- );
+ let (mesh_handle, material_handle) =
+ ensure_kitty_plane_assets(object, &plane_layout, &image_handle, ctx);
ctx.plane_children.push(
commands
.spawn((
@@ -733,6 +743,9 @@ fn sync_kitty_inline_image(
Mesh3d(mesh_handle),
MeshMaterial3d(material_handle),
Transform::default(),
+ // Warp and Mobius morphing move the vertices far outside the
+ // AABB cached at spawn, like the terminal planes.
+ NoFrustumCulling,
))
.id(),
);
@@ -753,28 +766,25 @@ fn ensure_kitty_plane_assets(
object: &mut crate::inline::KittyInlineObject,
layout: &InlineKittyPlaneLayout,
image_handle: &Handle,
- warp_amount: f32,
- elapsed_secs: f32,
- materials: &mut Assets,
- meshes: &mut Assets,
+ ctx: &mut KittyRenderContext<'_>,
) -> (Handle, Handle) {
let needs_rebuild = object.plane.as_ref().is_none_or(|cache| {
cache.x_segments != layout.x_segments || cache.y_segments != layout.y_segments
});
if needs_rebuild {
if let Some(cache) = object.plane.take() {
- meshes.remove(&cache.mesh);
- materials.remove(&cache.material);
+ ctx.meshes.remove(&cache.mesh);
+ ctx.materials.remove(&cache.material);
}
- let mesh = build_kitty_plane_mesh(layout, warp_amount, elapsed_secs);
- let mesh_handle = meshes.add(mesh);
- let material_handle = materials.add(StandardMaterial {
- base_color: Color::WHITE,
- base_color_texture: Some(image_handle.clone()),
- alpha_mode: AlphaMode::Blend,
- unlit: true,
- ..default()
- });
+ let mesh = build_kitty_plane_mesh(
+ layout,
+ ctx.mode,
+ ctx.warp_amount,
+ ctx.elapsed_secs,
+ ctx.mobius_progress,
+ );
+ let mesh_handle = ctx.meshes.add(mesh);
+ let material_handle = ctx.materials.add(kitty_plane_material(image_handle));
object.plane = Some(crate::inline::KittyPlaneCache {
x_segments: layout.x_segments,
y_segments: layout.y_segments,
@@ -785,19 +795,39 @@ fn ensure_kitty_plane_assets(
}
let cache = object.plane.as_mut().expect("plane cache should exist");
- if let Some(mut mesh) = meshes.get_mut(&cache.mesh) {
- write_kitty_plane_positions(&mut mesh, layout, warp_amount, elapsed_secs);
+ if let Some(mut mesh) = ctx.meshes.get_mut(&cache.mesh) {
+ write_kitty_plane_positions(
+ &mut mesh,
+ layout,
+ ctx.mode,
+ ctx.warp_amount,
+ ctx.elapsed_secs,
+ ctx.mobius_progress,
+ );
}
- if let Some(mut material) = materials.get_mut(&cache.material) {
+ if let Some(mut material) = ctx.materials.get_mut(&cache.material) {
material.base_color_texture = Some(image_handle.clone());
}
(cache.mesh.clone(), cache.material.clone())
}
+fn kitty_plane_material(image_handle: &Handle) -> StandardMaterial {
+ StandardMaterial {
+ base_color: Color::WHITE,
+ base_color_texture: Some(image_handle.clone()),
+ alpha_mode: AlphaMode::Blend,
+ cull_mode: None,
+ unlit: true,
+ ..default()
+ }
+}
+
fn build_kitty_plane_mesh(
layout: &InlineKittyPlaneLayout,
+ mode: TerminalPresentationMode,
warp_amount: f32,
elapsed_secs: f32,
+ mobius_progress: f32,
) -> Mesh {
let vertex_count = ((layout.x_segments + 1) * (layout.y_segments + 1)) as usize;
let mut positions = Vec::with_capacity(vertex_count);
@@ -811,11 +841,7 @@ fn build_kitty_plane_mesh(
for x in 0..=layout.x_segments {
let u = x as f32 / layout.x_segments as f32;
let px = layout.local_x + (u - 0.5) * layout.local_width;
- positions.push([
- px,
- py,
- plane_surface_z(px, py, warp_amount, elapsed_secs) + 1.5,
- ]);
+ positions.push([px, py, 0.0]);
uvs.push([u, v]);
}
}
@@ -832,21 +858,32 @@ fn build_kitty_plane_mesh(
}
}
- Mesh::new(
+ let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
bevy::asset::RenderAssetUsages::default(),
)
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
.with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
.with_inserted_attribute(Mesh::ATTRIBUTE_UV_0, uvs)
- .with_inserted_indices(Indices::U32(indices))
+ .with_inserted_indices(Indices::U32(indices));
+ write_kitty_plane_positions(
+ &mut mesh,
+ layout,
+ mode,
+ warp_amount,
+ elapsed_secs,
+ mobius_progress,
+ );
+ mesh
}
fn write_kitty_plane_positions(
mesh: &mut Mesh,
layout: &InlineKittyPlaneLayout,
+ mode: TerminalPresentationMode,
warp_amount: f32,
elapsed_secs: f32,
+ mobius_progress: f32,
) {
let Some(VertexAttributeValues::Float32x3(positions)) =
mesh.attribute_mut(Mesh::ATTRIBUTE_POSITION)
@@ -862,11 +899,16 @@ fn write_kitty_plane_positions(
let u = x as f32 / layout.x_segments as f32;
let px = layout.local_x + (u - 0.5) * layout.local_width;
if index < positions.len() {
- positions[index] = [
+ let point = plane_surface_point(
+ mode,
px,
py,
- plane_surface_z(px, py, warp_amount, elapsed_secs) + 1.5,
- ];
+ warp_amount,
+ elapsed_secs,
+ 1.5,
+ mobius_progress,
+ );
+ positions[index] = point.to_array();
}
index += 1;
}
@@ -878,26 +920,65 @@ fn write_kitty_plane_positions(
/// This runs after [`sync_inline_objects`] and updates cached plane mesh positions in place when
/// warp is active, instead of rebuilding inline entities every frame.
pub(crate) fn animate_inline_kitty_planes(
- presentation: Res,
+ camera_slots: Res,
+ mobius_transition: Res,
warp: Res,
time: Res,
+ mut previous_mode: Local>,
query: Query<(&InlineKittyPlaneLayout, &Mesh3d), With>,
mut meshes: ResMut>,
) {
- if !matches!(
- presentation.mode,
- TerminalPresentationMode::Plane3d | TerminalPresentationMode::Mobius3d
- ) || warp.amount <= 0.0
- {
+ let mode = camera_slots.active().mode;
+ let mode_changed = previous_mode.as_ref() != Some(&mode);
+ *previous_mode = Some(mode);
+ if !should_animate_inline_kitty_planes(
+ mode,
+ warp.amount,
+ warp.is_changed(),
+ mode_changed,
+ mobius_transition.active,
+ mobius_transition.is_changed(),
+ ) {
return;
}
let elapsed_secs = time.elapsed_secs();
+ let mobius_progress = active_mobius_progress(mode, &mobius_transition);
for (layout, mesh3d) in query.iter() {
let Some(mut mesh) = meshes.get_mut(&mesh3d.0) else {
continue;
};
- write_kitty_plane_positions(&mut mesh, layout, warp.amount, elapsed_secs);
+ write_kitty_plane_positions(
+ &mut mesh,
+ layout,
+ mode,
+ warp.amount,
+ elapsed_secs,
+ mobius_progress,
+ );
+ }
+}
+
+fn should_animate_inline_kitty_planes(
+ mode: TerminalPresentationMode,
+ warp_amount: f32,
+ warp_changed: bool,
+ mode_changed: bool,
+ mobius_transition_active: bool,
+ mobius_transition_changed: bool,
+) -> bool {
+ match mode {
+ TerminalPresentationMode::Flat2d => false,
+ TerminalPresentationMode::Plane3d | TerminalPresentationMode::Perspective3d => {
+ mode_changed || warp_changed || warp_amount > 0.0
+ }
+ TerminalPresentationMode::Mobius3d => {
+ mode_changed
+ || mobius_transition_active
+ || mobius_transition_changed
+ || warp_changed
+ || warp_amount > 0.0
+ }
}
}
@@ -1059,7 +1140,7 @@ pub(crate) struct RgpSyncParams<'w, 's> {
app_config: Res<'w, AppConfig>,
terminal: Res<'w, TerminalSurface>,
viewport: Res<'w, TerminalViewport>,
- presentation: Res<'w, TerminalPresentation>,
+ camera_slots: Res<'w, TerminalCameraSlots>,
mobius_transition: Res<'w, MobiusTransition>,
plane_warp: Res<'w, TerminalPlaneWarp>,
time: Res<'w, Time>,
@@ -1089,7 +1170,7 @@ pub(crate) fn sync_rgp_objects(mut params: RgpSyncParams) {
app_config,
terminal,
viewport,
- presentation,
+ camera_slots,
mobius_transition,
plane_warp,
time,
@@ -1100,7 +1181,8 @@ pub(crate) fn sync_rgp_objects(mut params: RgpSyncParams) {
let cell_width = viewport.size.x / terminal.cols.max(1) as f32;
let cell_height = viewport.size.y / terminal.rows.max(1) as f32;
let elapsed_secs = time.elapsed_secs();
- let mobius_progress = active_mobius_progress(presentation.mode, mobius_transition);
+ let mode = camera_slots.active().mode;
+ let mobius_progress = active_mobius_progress(mode, mobius_transition);
for (object, mut transform, mut visibility) in query.iter_mut() {
let Some(anchor) = inline_objects.anchors.get(&object.object_id) else {
@@ -1141,39 +1223,35 @@ pub(crate) fn sync_rgp_objects(mut params: RgpSyncParams) {
let object_rotation = base_oblique * explicit_rotation * animated_rotation;
let object_scale = Vec3::splat(scale) * scale3;
- match presentation.mode {
- TerminalPresentationMode::Flat2d => {
- transform.translation = Vec3::new(
- layout.center_x
- + anchor.style.offset.x * (terminal.pixmap_dimensions().x as f32),
- layout.center_y
- + bob
- + anchor.style.offset.y * (terminal.pixmap_dimensions().y as f32),
- CURSOR_DEPTH + anchor.style.depth * 4.0 + anchor.style.offset.z,
- );
- transform.rotation = object_rotation;
- transform.scale = object_scale;
- *visibility = Visibility::Visible;
- }
- TerminalPresentationMode::Plane3d | TerminalPresentationMode::Mobius3d => {
- let Ok(plane_transform) = plane_query.single() else {
- *visibility = Visibility::Hidden;
- continue;
- };
- let local_position = plane_surface_point(
- presentation.mode,
- layout.local_x,
- layout.local_y,
- plane_warp.amount,
- elapsed_secs,
- 8.0 + anchor.style.depth * 1.5,
- mobius_progress,
- ) + anchor.style.offset;
- transform.translation = plane_transform.transform_point(local_position);
- transform.rotation = plane_transform.rotation * object_rotation;
- transform.scale = object_scale;
- *visibility = Visibility::Visible;
- }
+ if mode.is_3d() {
+ let Ok(plane_transform) = plane_query.single() else {
+ *visibility = Visibility::Hidden;
+ continue;
+ };
+ let local_position = plane_surface_point(
+ mode,
+ layout.local_x,
+ layout.local_y,
+ plane_warp.amount,
+ elapsed_secs,
+ 8.0 + anchor.style.depth * 1.5,
+ mobius_progress,
+ ) + anchor.style.offset;
+ transform.translation = plane_transform.transform_point(local_position);
+ transform.rotation = plane_transform.rotation * object_rotation;
+ transform.scale = object_scale;
+ *visibility = Visibility::Visible;
+ } else {
+ transform.translation = Vec3::new(
+ layout.center_x + anchor.style.offset.x * (terminal.pixmap_dimensions().x as f32),
+ layout.center_y
+ + bob
+ + anchor.style.offset.y * (terminal.pixmap_dimensions().y as f32),
+ CURSOR_DEPTH + anchor.style.depth * 4.0 + anchor.style.offset.z,
+ );
+ transform.rotation = object_rotation;
+ transform.scale = object_scale;
+ *visibility = Visibility::Visible;
}
}
}
@@ -1220,6 +1298,7 @@ pub(crate) fn apply_instance_brightness(mut params: BrightnessParams) {
materials,
commands,
} = &mut params;
+
if material_query.is_empty() {
return;
}
@@ -1396,57 +1475,82 @@ fn extrude_mesh(mesh: Mesh, depth: f32) -> Mesh {
/// even when the terminal contents are otherwise static.
pub fn animate_terminal_plane_warp(
time: Res,
- presentation: Res,
+ camera_slots: Res,
mobius_transition: Res,
warp: Res,
plane_meshes: Res,
mut meshes: ResMut>,
) {
- if presentation.mode == TerminalPresentationMode::Flat2d {
+ let mode = camera_slots.active().mode;
+ if mode == TerminalPresentationMode::Flat2d {
return;
}
- let needs_update = match presentation.mode {
- TerminalPresentationMode::Flat2d => false,
- TerminalPresentationMode::Plane3d => {
- presentation.is_changed() || warp.is_changed() || warp.amount > 0.0
- }
- // Reapply the strip every frame so mode switches and time-based motion are visible.
- TerminalPresentationMode::Mobius3d => true,
- };
+ let needs_update = should_animate_terminal_plane_warp(
+ mode,
+ warp.amount,
+ camera_slots.is_changed() || warp.is_changed(),
+ mobius_transition.active,
+ mobius_transition.is_changed(),
+ );
if !needs_update {
return;
}
- let pulse = warp.amount * (0.96 + 0.04 * (time.elapsed_secs() * 2.2).sin());
- let mobius_progress = active_mobius_progress(presentation.mode, &mobius_transition);
+ let mobius_progress = active_mobius_progress(mode, &mobius_transition);
apply_plane_warp(
meshes.get_mut(&plane_meshes.front),
- presentation.mode,
- pulse,
- time.elapsed_secs(),
- -1.0,
- mobius_progress,
- );
- apply_plane_warp(
- meshes.get_mut(&plane_meshes.back),
- presentation.mode,
- pulse,
+ mode,
+ warp.amount,
time.elapsed_secs(),
1.0,
mobius_progress,
);
+ // The back sheet is hidden in Mobius mode; skipping it avoids uploading a
+ // mesh nothing renders.
+ if !mode.is_mobius() {
+ apply_plane_warp(
+ meshes.get_mut(&plane_meshes.back),
+ mode,
+ warp.amount,
+ time.elapsed_secs(),
+ -1.0,
+ mobius_progress,
+ );
+ }
+}
+
+fn should_animate_terminal_plane_warp(
+ mode: TerminalPresentationMode,
+ warp_amount: f32,
+ state_changed: bool,
+ mobius_transition_active: bool,
+ mobius_transition_changed: bool,
+) -> bool {
+ match mode {
+ TerminalPresentationMode::Flat2d => false,
+ TerminalPresentationMode::Plane3d | TerminalPresentationMode::Perspective3d => {
+ state_changed || warp_amount > 0.0
+ }
+ // A settled strip with no warp is time-invariant; transition frames
+ // (including the finish frame, via change detection) and mode or pose
+ // changes still reapply it.
+ TerminalPresentationMode::Mobius3d => {
+ state_changed
+ || warp_amount > 0.0
+ || mobius_transition_active
+ || mobius_transition_changed
+ }
+ }
}
/// Advances the Mobius transition and restores normal 3D interaction when it completes.
pub fn animate_mobius_transition(
time: Res,
- mut presentation: ResMut,
+ mut camera_slots: ResMut,
mut mobius_transition: ResMut,
- mut plane_view: ResMut,
- mut redraw: ResMut,
) {
- if presentation.mode != TerminalPresentationMode::Mobius3d {
+ if camera_slots.active().mode != TerminalPresentationMode::Mobius3d {
mobius_transition.stop();
return;
}
@@ -1456,18 +1560,28 @@ pub fn animate_mobius_transition(
}
mobius_transition.elapsed_secs += time.delta_secs();
- redraw.request();
if mobius_transition.finished() {
- plane_view.zoom = mobius_transition.end_zoom.max(0.1);
if mobius_transition.direction == crate::scene::MobiusTransitionDirection::Exiting {
- plane_view.yaw = mobius_transition.source_yaw;
- plane_view.pitch = mobius_transition.source_pitch;
- plane_view.camera_offset = mobius_transition.source_camera_offset;
- presentation.mode = mobius_transition.source_mode;
+ let preset = camera_slots.active_mut();
+ let mut fallback_pose = preset.pose;
+ fallback_pose.orthographic_scale = mobius_transition.source_zoom;
+ fallback_pose.yaw = mobius_transition.source_yaw;
+ fallback_pose.pitch = mobius_transition.source_pitch;
+ fallback_pose.roll = mobius_transition.source_roll;
+ fallback_pose.translation = mobius_transition.source_translation;
+ let source = preset.mobius_source.unwrap_or(TerminalMobiusSource {
+ mode: mobius_transition.source_mode,
+ pose: fallback_pose,
+ });
+ preset.mode = source.mode;
+ preset.pose = source.pose;
+ preset.mobius_source = None;
+ } else {
+ camera_slots.active_mut().pose.orthographic_scale =
+ mobius_transition.end_zoom.max(MIN_ORTHOGRAPHIC_SCALE);
}
mobius_transition.stop();
- redraw.request();
}
}
@@ -1489,7 +1603,7 @@ fn active_mobius_progress(
fn apply_plane_warp(
mesh: Option>,
mode: TerminalPresentationMode,
- pulse: f32,
+ warp_amount: f32,
elapsed_secs: f32,
direction: f32,
mobius_progress: f32,
@@ -1510,13 +1624,20 @@ fn apply_plane_warp(
for (position, uv) in positions.iter_mut().zip(uvs.iter()) {
let x = uv[0] - 0.5;
let y = 0.5 - uv[1];
- let point = plane_surface_point(mode, x, y, pulse, elapsed_secs, 0.0, mobius_progress);
+ let point =
+ plane_surface_point(mode, x, y, warp_amount, elapsed_secs, 0.0, mobius_progress);
position[0] = point.x;
position[1] = point.y;
- position[2] = match mode {
- TerminalPresentationMode::Plane3d => point.z * direction,
- TerminalPresentationMode::Flat2d | TerminalPresentationMode::Mobius3d => point.z,
- };
+ position[2] = oriented_plane_depth(mode, point.z, direction);
+ }
+}
+
+fn oriented_plane_depth(mode: TerminalPresentationMode, depth: f32, direction: f32) -> f32 {
+ match mode {
+ TerminalPresentationMode::Plane3d | TerminalPresentationMode::Perspective3d => {
+ depth * direction
+ }
+ TerminalPresentationMode::Flat2d | TerminalPresentationMode::Mobius3d => depth,
}
}
@@ -1527,7 +1648,7 @@ pub(crate) struct CursorSyncParams<'w, 's> {
runtime: Res<'w, TerminalRuntime>,
terminal: Res<'w, TerminalSurface>,
viewport: Res<'w, TerminalViewport>,
- presentation: Res<'w, TerminalPresentation>,
+ camera_slots: Res<'w, TerminalCameraSlots>,
mobius_transition: Res<'w, MobiusTransition>,
plane_warp: Res<'w, TerminalPlaneWarp>,
time: Res<'w, Time>,
@@ -1549,7 +1670,7 @@ pub(crate) fn sync_asset_to_terminal_cursor(mut params: CursorSyncParams) {
runtime,
terminal,
viewport,
- presentation,
+ camera_slots,
mobius_transition,
plane_warp,
time,
@@ -1564,9 +1685,9 @@ pub(crate) fn sync_asset_to_terminal_cursor(mut params: CursorSyncParams) {
runtime,
terminal,
viewport,
- mode: presentation.mode,
+ mode: camera_slots.active().mode,
plane_warp_amount: plane_warp.amount,
- mobius_progress: active_mobius_progress(presentation.mode, mobius_transition),
+ mobius_progress: active_mobius_progress(camera_slots.active().mode, mobius_transition),
elapsed_secs: time.elapsed_secs(),
plane_query,
};
@@ -1608,8 +1729,8 @@ fn cursor_pose(
0.0
};
- let (translation, rotation, visibility) = match ctx.mode {
- TerminalPresentationMode::Flat2d => (
+ let (translation, rotation, visibility) = if !ctx.mode.is_3d() {
+ (
Vec3::new(local_x, local_y + bob, CURSOR_DEPTH),
Quat::from_rotation_y(spin) * Quat::from_rotation_x(-0.25),
if !app_config.cursor.model.visible || screen.hide_cursor() {
@@ -1617,33 +1738,31 @@ fn cursor_pose(
} else {
Visibility::Visible
},
- ),
- TerminalPresentationMode::Plane3d | TerminalPresentationMode::Mobius3d => {
- let Ok(plane_transform) = ctx.plane_query.single() else {
- return (Vec3::ZERO, Quat::IDENTITY, scale, Visibility::Hidden);
- };
- let plane_local_x = cursor_x / cols - 0.5;
- let plane_local_y = 0.5 - (cursor_row + 0.5) / rows + plane_bob;
- let local_position = plane_surface_point(
- ctx.mode,
- plane_local_x,
- plane_local_y,
- ctx.plane_warp_amount,
- ctx.elapsed_secs,
- app_config.cursor.model.plane_offset,
- ctx.mobius_progress,
- );
- (
- plane_transform.transform_point(local_position),
- plane_transform.rotation
- * (Quat::from_rotation_y(spin) * Quat::from_rotation_x(-0.25)),
- if app_config.cursor.model.visible {
- Visibility::Visible
- } else {
- Visibility::Hidden
- },
- )
- }
+ )
+ } else {
+ let Ok(plane_transform) = ctx.plane_query.single() else {
+ return (Vec3::ZERO, Quat::IDENTITY, scale, Visibility::Hidden);
+ };
+ let plane_local_x = cursor_x / cols - 0.5;
+ let plane_local_y = 0.5 - (cursor_row + 0.5) / rows + plane_bob;
+ let local_position = plane_surface_point(
+ ctx.mode,
+ plane_local_x,
+ plane_local_y,
+ ctx.plane_warp_amount,
+ ctx.elapsed_secs,
+ app_config.cursor.model.plane_offset,
+ ctx.mobius_progress,
+ );
+ (
+ plane_transform.transform_point(local_position),
+ plane_transform.rotation * (Quat::from_rotation_y(spin) * Quat::from_rotation_x(-0.25)),
+ if app_config.cursor.model.visible {
+ Visibility::Visible
+ } else {
+ Visibility::Hidden
+ },
+ )
};
(translation, rotation, scale, visibility)
@@ -1672,10 +1791,10 @@ fn plane_surface_point(
) -> Vec3 {
match mode {
TerminalPresentationMode::Flat2d => Vec3::new(local_x, local_y, depth_offset),
- TerminalPresentationMode::Plane3d => Vec3::new(
+ TerminalPresentationMode::Plane3d | TerminalPresentationMode::Perspective3d => Vec3::new(
local_x,
local_y,
- plane_surface_z(local_x, local_y, warp_amount, elapsed_secs) + depth_offset,
+ -plane_surface_z(local_x, local_y, warp_amount, elapsed_secs) + depth_offset,
),
TerminalPresentationMode::Mobius3d => {
let source_point = Vec3::new(local_x, local_y, depth_offset);
@@ -1708,3 +1827,426 @@ fn mobius_surface_point(
width * sin_half * 320.0 + depth_offset,
)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::scene::MobiusEnterZoomFloor;
+
+ #[derive(Resource, Default)]
+ struct CameraChangedProbe(bool);
+
+ #[derive(Resource, Default)]
+ struct VisibilityChangedProbe(usize);
+
+ fn record_camera_change(
+ camera_slots: Res,
+ mut probe: ResMut,
+ ) {
+ probe.0 = camera_slots.is_changed();
+ }
+
+ fn record_visibility_changes(
+ visibility: Query<(), Changed>,
+ mut probe: ResMut,
+ ) {
+ probe.0 = visibility.iter().count();
+ }
+
+ #[test]
+ fn brightness_system_does_not_mutate_camera_state() {
+ let mut app = App::new();
+ app.init_resource::()
+ .init_resource::()
+ .init_resource::>()
+ .init_resource::()
+ .init_resource::()
+ .add_systems(
+ Update,
+ (
+ apply_instance_brightness,
+ record_camera_change.after(apply_instance_brightness),
+ ),
+ );
+
+ app.update();
+ app.world_mut().resource_mut::().0 = false;
+ app.update();
+
+ assert!(!app.world().resource::().0);
+ }
+
+ #[test]
+ fn pose_updates_do_not_mark_inline_visibility_changed() {
+ let mut app = App::new();
+ app.init_resource::()
+ .init_resource::()
+ .add_systems(
+ Update,
+ (apply_inline_objects, record_visibility_changes).chain(),
+ );
+ app.world_mut()
+ .spawn((TerminalInlineObjectSprite, Visibility::Visible));
+ app.world_mut()
+ .spawn((TerminalInlineObjectPlane, Visibility::Hidden));
+ app.update();
+ app.world_mut().clear_trackers();
+ app.world_mut()
+ .resource_mut::()
+ .active_mut()
+ .pose
+ .yaw += 0.25;
+
+ app.update();
+
+ assert_eq!(app.world().resource::().0, 0);
+ }
+
+ #[test]
+ fn perspective_uses_the_same_oriented_warp_as_orthographic_3d() {
+ for mode in [
+ TerminalPresentationMode::Plane3d,
+ TerminalPresentationMode::Perspective3d,
+ ] {
+ let surface = plane_surface_point(mode, 0.0, 0.0, 0.75, 1.0, 0.0, 0.0);
+ let object = plane_surface_point(mode, 0.0, 0.0, 0.75, 1.0, 8.0, 0.0);
+ assert_eq!(
+ oriented_plane_depth(mode, surface.z, 1.0),
+ surface.z,
+ "front mesh must use the shared object surface"
+ );
+ assert!((object.z - surface.z - 8.0).abs() < f32::EPSILON);
+ }
+
+ let orthographic = plane_surface_point(
+ TerminalPresentationMode::Plane3d,
+ 0.0,
+ 0.0,
+ 0.75,
+ 1.0,
+ 0.0,
+ 0.0,
+ );
+ let perspective = plane_surface_point(
+ TerminalPresentationMode::Perspective3d,
+ 0.0,
+ 0.0,
+ 0.75,
+ 1.0,
+ 0.0,
+ 0.0,
+ );
+ assert_eq!(perspective, orthographic);
+ }
+
+ #[test]
+ fn every_3d_mode_animates_warped_kitty_planes() {
+ for mode in [
+ TerminalPresentationMode::Plane3d,
+ TerminalPresentationMode::Perspective3d,
+ TerminalPresentationMode::Mobius3d,
+ ] {
+ assert!(should_animate_inline_kitty_planes(
+ mode, 0.5, false, false, false, false
+ ));
+ }
+ assert!(!should_animate_inline_kitty_planes(
+ TerminalPresentationMode::Flat2d,
+ 0.5,
+ true,
+ true,
+ true,
+ true
+ ));
+ assert!(should_animate_inline_kitty_planes(
+ TerminalPresentationMode::Perspective3d,
+ 0.0,
+ true,
+ false,
+ false,
+ false
+ ));
+ assert!(should_animate_inline_kitty_planes(
+ TerminalPresentationMode::Plane3d,
+ 0.0,
+ false,
+ true,
+ false,
+ false
+ ));
+ assert!(!should_animate_inline_kitty_planes(
+ TerminalPresentationMode::Mobius3d,
+ 0.0,
+ false,
+ false,
+ false,
+ false
+ ));
+ assert!(should_animate_inline_kitty_planes(
+ TerminalPresentationMode::Mobius3d,
+ 0.0,
+ false,
+ false,
+ true,
+ false
+ ));
+ assert!(should_animate_inline_kitty_planes(
+ TerminalPresentationMode::Mobius3d,
+ 0.0,
+ false,
+ false,
+ false,
+ true
+ ));
+ assert!(!should_animate_inline_kitty_planes(
+ TerminalPresentationMode::Perspective3d,
+ 0.0,
+ false,
+ false,
+ false,
+ false
+ ));
+ }
+
+ #[test]
+ fn settled_mobius_mode_stops_re_uploading_the_terminal_meshes() {
+ // Settled strip, no warp, nothing changed: no idle upload.
+ assert!(!should_animate_terminal_plane_warp(
+ TerminalPresentationMode::Mobius3d,
+ 0.0,
+ false,
+ false,
+ false
+ ));
+ // Transition frames, the finish frame, and state changes still update.
+ assert!(should_animate_terminal_plane_warp(
+ TerminalPresentationMode::Mobius3d,
+ 0.0,
+ false,
+ true,
+ false
+ ));
+ assert!(should_animate_terminal_plane_warp(
+ TerminalPresentationMode::Mobius3d,
+ 0.0,
+ false,
+ false,
+ true
+ ));
+ assert!(should_animate_terminal_plane_warp(
+ TerminalPresentationMode::Mobius3d,
+ 0.0,
+ true,
+ false,
+ false
+ ));
+ assert!(should_animate_terminal_plane_warp(
+ TerminalPresentationMode::Mobius3d,
+ 0.5,
+ false,
+ false,
+ false
+ ));
+ }
+
+ #[test]
+ fn completed_mobius_transition_updates_the_final_kitty_frame() {
+ let layout = InlineKittyPlaneLayout {
+ local_x: 0.0,
+ local_y: 0.0,
+ local_width: 0.2,
+ local_height: 0.2,
+ x_segments: 2,
+ y_segments: 2,
+ };
+ let mesh =
+ build_kitty_plane_mesh(&layout, TerminalPresentationMode::Mobius3d, 0.0, 0.0, 0.0);
+
+ let mut slots = TerminalCameraSlots::default();
+ slots.active_mut().mode = TerminalPresentationMode::Mobius3d;
+ let pose = slots.active().pose;
+ let mut transition = MobiusTransition::default();
+ transition.begin_enter(
+ 0,
+ &TerminalMobiusSource {
+ mode: TerminalPresentationMode::Plane3d,
+ pose,
+ },
+ &pose,
+ MobiusEnterZoomFloor::KeyboardTarget,
+ );
+ transition.elapsed_secs = MobiusTransition::ZOOM_OUT_SECS;
+
+ let mut app = App::new();
+ app.insert_resource(slots)
+ .insert_resource(transition)
+ .init_resource::()
+ .init_resource::()
+ .init_resource::>()
+ .add_systems(
+ Update,
+ animate_inline_kitty_planes.after(animate_mobius_transition),
+ )
+ .add_systems(Update, animate_mobius_transition);
+ let mesh_handle = app.world_mut().resource_mut::>().add(mesh);
+ app.world_mut().spawn((
+ TerminalInlineObjectPlane,
+ layout,
+ Mesh3d(mesh_handle.clone()),
+ ));
+
+ app.update();
+ app.world_mut()
+ .resource_mut::()
+ .elapsed_secs = MobiusTransition::ZOOM_OUT_SECS + MobiusTransition::MORPH_SECS;
+ app.update();
+
+ let meshes = app.world().resource::>();
+ let mesh = meshes.get(&mesh_handle).expect("Kitty mesh");
+ let Some(VertexAttributeValues::Float32x3(positions)) =
+ mesh.attribute(Mesh::ATTRIBUTE_POSITION)
+ else {
+ panic!("expected Kitty mesh positions");
+ };
+ let center = Vec3::from_array(positions[4]);
+ let expected = plane_surface_point(
+ TerminalPresentationMode::Mobius3d,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.5,
+ 1.0,
+ );
+ assert_eq!(center, expected);
+ }
+
+ #[test]
+ fn kitty_plane_vertices_follow_the_mobius_surface() {
+ let layout = InlineKittyPlaneLayout {
+ local_x: 0.0,
+ local_y: 0.0,
+ local_width: 0.2,
+ local_height: 0.2,
+ x_segments: 2,
+ y_segments: 2,
+ };
+ let mesh =
+ build_kitty_plane_mesh(&layout, TerminalPresentationMode::Mobius3d, 0.0, 0.0, 1.0);
+ let Some(VertexAttributeValues::Float32x3(positions)) =
+ mesh.attribute(Mesh::ATTRIBUTE_POSITION)
+ else {
+ panic!("expected Kitty mesh positions");
+ };
+ let center = Vec3::from_array(positions[4]);
+ let expected = plane_surface_point(
+ TerminalPresentationMode::Mobius3d,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.5,
+ 1.0,
+ );
+
+ assert_eq!(center, expected);
+ assert_ne!(center.x, 0.0);
+ }
+
+ #[test]
+ fn kitty_planes_are_visible_from_both_sides() {
+ let image = Handle::::default();
+ let material = kitty_plane_material(&image);
+
+ assert_eq!(material.cull_mode, None);
+ }
+
+ #[test]
+ fn keyboard_mobius_enter_finish_applies_the_target_zoom_floor() {
+ let sub_unit_pose = crate::camera::TerminalCameraPose {
+ orthographic_scale: 0.4,
+ ..crate::camera::TerminalCameraPose::default()
+ };
+ let source = TerminalMobiusSource {
+ mode: TerminalPresentationMode::Plane3d,
+ pose: sub_unit_pose,
+ };
+
+ let mut slots = TerminalCameraSlots::default();
+ slots.active_mut().mode = TerminalPresentationMode::Mobius3d;
+ slots.active_mut().pose = sub_unit_pose;
+ slots.active_mut().mobius_source = Some(source);
+
+ let mut transition = MobiusTransition::default();
+ transition.begin_enter(
+ 0,
+ &source,
+ &sub_unit_pose,
+ MobiusEnterZoomFloor::KeyboardTarget,
+ );
+ transition.elapsed_secs = MobiusTransition::ZOOM_OUT_SECS + MobiusTransition::MORPH_SECS;
+
+ let mut app = App::new();
+ app.insert_resource(slots)
+ .insert_resource(transition)
+ .init_resource::()
+ .add_systems(Update, animate_mobius_transition);
+ app.update();
+
+ let preset = app.world().resource::().active();
+ // The keyboard enter is the one finish write that is not a no-op: the
+ // displayed pose is zoomed out to the target floor...
+ assert_eq!(
+ preset.pose.orthographic_scale,
+ MobiusTransition::TARGET_ZOOM_MULTIPLIER
+ );
+ // ...while the saved source keeps the exact sub-unit scale so the
+ // exit restores it.
+ assert_eq!(
+ preset
+ .mobius_source
+ .expect("Mobius source")
+ .pose
+ .orthographic_scale,
+ 0.4
+ );
+ assert!(!app.world().resource::().active);
+ }
+
+ #[test]
+ fn mobius_exit_restores_the_complete_per_slot_source_pose() {
+ let source_pose = crate::camera::TerminalCameraPose {
+ orthographic_scale: MIN_ORTHOGRAPHIC_SCALE,
+ roll: 0.4,
+ perspective_fov: 0.8,
+ ..crate::camera::TerminalCameraPose::default()
+ };
+ let source = TerminalMobiusSource {
+ mode: TerminalPresentationMode::Plane3d,
+ pose: source_pose,
+ };
+
+ let mut slots = TerminalCameraSlots::default();
+ slots.active_mut().mode = TerminalPresentationMode::Mobius3d;
+ slots.active_mut().pose.orthographic_scale = 1.0;
+ slots.active_mut().mobius_source = Some(source);
+
+ let mut transition = MobiusTransition::default();
+ transition.prepare_source(0, source.mode, &source.pose);
+ let active_pose = slots.active().pose;
+ transition.begin_exit(0, &active_pose, 1.0);
+ transition.elapsed_secs = MobiusTransition::VIEW_RESET_SECS + MobiusTransition::MORPH_SECS;
+
+ let mut app = App::new();
+ app.insert_resource(slots)
+ .insert_resource(transition)
+ .init_resource::()
+ .add_systems(Update, animate_mobius_transition);
+ app.update();
+
+ let preset = app.world().resource::().active();
+ assert_eq!(preset.mode, source.mode);
+ assert_eq!(preset.pose, source.pose);
+ assert_eq!(preset.mobius_source, None);
+ }
+}
diff --git a/widget/examples/big_rat.rs b/widget/examples/big_rat.rs
index 9f8036a..f0553f0 100644
--- a/widget/examples/big_rat.rs
+++ b/widget/examples/big_rat.rs
@@ -1,4 +1,4 @@
-use std::io;
+use std::io::{self, Write};
use crossterm::event::{self, Event, KeyCode};
use ratatui::{
@@ -26,6 +26,8 @@ fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
.scale(1.0),
);
graphic.register()?;
+ let mut camera = CameraPreset::Ortho;
+ camera.apply()?;
let mut area = Rect::new(0, 0, 24, 10);
let mut centered = false;
@@ -54,6 +56,8 @@ fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
": animate ({}) ",
u8::from(graphic.settings().animate)
)),
+ Span::styled("v", Style::default().fg(Color::Cyan)),
+ Span::raw(format!(": camera ({}) ", camera.label())),
Span::styled("c", Style::default().fg(Color::Cyan)),
Span::raw(": clear "),
Span::styled("r", Style::default().fg(Color::Cyan)),
@@ -104,6 +108,10 @@ fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
graphic.settings_mut().animate = !animate;
send_update = true;
}
+ (KeyCode::Char('v'), _) => {
+ camera = camera.next();
+ camera.apply()?;
+ }
(KeyCode::Char('r'), _) => {
area = Rect::new(0, 0, 24, 10);
graphic.settings_mut().animate = true;
@@ -113,6 +121,8 @@ fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
.animate(true)
.scale(1.0)
.brightness(0.9);
+ camera = CameraPreset::Ortho;
+ camera.apply()?;
centered = false;
send_update = true;
}
@@ -179,6 +189,54 @@ fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
}
}
+/// Camera presets cycled with the `v` key, sent as RGP `c` commands.
+/// `scale` is orthographic zoom; `fov` and all rotations are degrees.
+#[derive(Clone, Copy)]
+enum CameraPreset {
+ Flat,
+ Ortho,
+ Perspective,
+ Mobius,
+}
+
+impl CameraPreset {
+ fn next(self) -> Self {
+ match self {
+ Self::Flat => Self::Ortho,
+ Self::Ortho => Self::Perspective,
+ Self::Perspective => Self::Mobius,
+ Self::Mobius => Self::Flat,
+ }
+ }
+
+ fn label(self) -> &'static str {
+ match self {
+ Self::Flat => "flat",
+ Self::Ortho => "ortho",
+ Self::Perspective => "persp",
+ Self::Mobius => "mobius",
+ }
+ }
+
+ fn apply(self) -> io::Result<()> {
+ let sequence = match self {
+ Self::Flat => "\x1b_ratty;g;c;id=0;set=1;type=Flat\x1b\\".to_string(),
+ Self::Ortho => {
+ "\x1b_ratty;g;c;id=0;set=1;type=Ortho;scale=0.95;rx=8;ry=18;rz=2\x1b\\".to_string()
+ }
+ Self::Perspective => {
+ "\x1b_ratty;g;c;id=0;set=1;type=Persp;fov=50;pz=40;rx=10;ry=20;rz=2\x1b\\"
+ .to_string()
+ }
+ Self::Mobius => {
+ "\x1b_ratty;g;c;id=0;set=1;type=Mobius;scale=0.9;rx=12;ry=24;rz=4\x1b\\".to_string()
+ }
+ };
+ io::stdout().write_all(sequence.as_bytes())?;
+ io::stdout().flush()
+ }
+}
+
fn clamp_rect(mut rect: Rect, bounds: Rect) -> Rect {
rect.width = rect.width.min(bounds.width.max(1));
rect.height = rect.height.min(bounds.height.max(1));