Skip to content

Commit e5b999c

Browse files
DocAtPromptclaude
andcommitted
Idle CPU throttle, moon phase direction, rustfmt pass
Polish before public push. 1. Idle CPU throttle. The main loop now drops to IDLE_FPS (1 fps) when auto-rotation is off (or running at real time with no input for the last 2 s). Real-time rotation only moves the subsolar point 0.004°/s, which is below the visual change threshold of one cell — so 1 fps is visually equivalent. Saves about 30× CPU when the tool sits in a corner of the desktop. AppState::is_idle() exposes the decision so the main loop stays declarative. 2. Moon phase direction. Status line now shows "↑" for waxing and "↓" for waning, derived from comparing illumination(now) vs. illumination(now + 6h). A dead-band of ±0.003 around the wendepunkt (full/new moon) leaves the arrow off when the phase is stationary. 3. cargo fmt --all + strict format check in CI. The CI fmt step was previously continue-on-error; with the codebase now formatted, the guard is hard so future contributions stay tidy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8962f71 commit e5b999c

10 files changed

Lines changed: 191 additions & 81 deletions

File tree

.github/workflows/ci.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,3 @@ jobs:
4949

5050
- name: Format check
5151
run: cargo fmt --all -- --check
52-
continue-on-error: true

src/app.rs

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@ use chrono::{DateTime, Utc};
88

99
use crate::camera::Camera;
1010
use crate::constants::{
11-
CELL_ASPECT_DEFAULT, CELL_ASPECT_MAX, CELL_ASPECT_MIN, CELL_ASPECT_STEP,
12-
CLOUD_RADIUS, ROT_SECONDS_PER_TURN_REALTIME, ROT_SPEED_STEPS,
11+
CELL_ASPECT_DEFAULT, CELL_ASPECT_MAX, CELL_ASPECT_MIN, CELL_ASPECT_STEP, CLOUD_RADIUS,
12+
ROT_SECONDS_PER_TURN_REALTIME, ROT_SPEED_STEPS,
1313
};
1414
use crate::render::{
15-
self, CITY_LIGHT_RGB, Ray, Star, class_color, glow_intensity, hit_to_geo,
16-
lighting, lights_visible, palette_day, ray_at_screen, ray_perp_to_origin,
17-
ray_sphere, rgb_to_ansi, star_at, sun_marker,
15+
self, class_color, glow_intensity, hit_to_geo, lighting, lights_visible, palette_day,
16+
ray_at_screen, ray_perp_to_origin, ray_sphere, rgb_to_ansi, star_at, sun_marker, Ray, Star,
17+
CITY_LIGHT_RGB,
1818
};
1919
use crate::sun;
2020
use crate::tui::{Cell, FrameBuffer};
21-
use crate::vec3::{V3, from_lat_lon, rotate_y};
21+
use crate::vec3::{from_lat_lon, rotate_y, V3};
2222
use crate::world;
2323

2424
pub const FINE_FACTOR: f64 = 0.1;
@@ -137,6 +137,20 @@ impl AppState {
137137

138138
// ----- Time -----------------------------------------------------------
139139

140+
/// True wenn das Tool gerade keine visuelle Bewegung pro Frame liefert.
141+
/// Im Idle drosselt der Main-Loop die Frame-Rate, um CPU zu sparen — der
142+
/// Subsolar-Punkt wandert in Realtime nur 0.004°/s, also reicht 1 fps.
143+
pub fn is_idle(&self) -> bool {
144+
if self.freeze {
145+
return true;
146+
}
147+
match self.auto_rotation {
148+
AutoRotation::Off => true,
149+
AutoRotation::On { speed_idx: 0 } => true,
150+
AutoRotation::On { .. } => false,
151+
}
152+
}
153+
140154
pub fn step(&mut self, dt: Duration) {
141155
if self.freeze {
142156
return;
@@ -353,13 +367,7 @@ impl AppState {
353367
}
354368
}
355369

356-
fn render_blocks(
357-
&self,
358-
fb: &mut FrameBuffer,
359-
cols: usize,
360-
render_rows: usize,
361-
sun_dir: V3,
362-
) {
370+
fn render_blocks(&self, fb: &mut FrameBuffer, cols: usize, render_rows: usize, sun_dir: V3) {
363371
// Welt-Y-Höhe einer Zelle = `cell_aspect` wide-units. Zwei Halbblock-
364372
// Sub-Pixel pro Zelle, jeweils gesampelt in deren Mitte.
365373
let sub_h = render_rows as f64 * self.cell_aspect;
@@ -484,10 +492,21 @@ impl AppState {
484492
// Mondphase: aktuell immer, weil sie als Live-Info sinnvoll ist
485493
let base_now = self.freeze_anchor.unwrap_or_else(Utc::now);
486494
let illum = crate::moon::illumination(base_now);
495+
// Phasen-Richtung: 6h später hat sich der Beleuchtungsanteil messbar
496+
// verschoben (Mondzyklus ~29.5 Tage → ~1.4 %-Punkte pro 6h im Mittel).
497+
let illum_later = crate::moon::illumination(base_now + chrono::Duration::hours(6));
498+
let direction = if illum_later > illum + 0.003 {
499+
" ↑"
500+
} else if illum_later < illum - 0.003 {
501+
" ↓"
502+
} else {
503+
""
504+
};
487505
let _ = write!(
488506
s,
489-
" | moon: {} {:.0}%",
507+
" | moon: {}{} {:.0}%",
490508
moon_phase_label(illum),
509+
direction,
491510
(illum * 100.0).round()
492511
);
493512
if self.freeze {
@@ -787,9 +806,36 @@ fn shade_ascii(
787806
return ('.', if color { rgb_to_ansi(30, 70, 150) } else { 15 });
788807
}
789808
match star_at(pix_x, pix_y) {
790-
Some(Star::Bright) => return ('*', if color { rgb_to_ansi(255, 255, 240) } else { 15 }),
791-
Some(Star::Medium) => return ('.', if color { rgb_to_ansi(180, 180, 180) } else { 15 }),
792-
Some(Star::Dim) => return ('·', if color { rgb_to_ansi(110, 110, 130) } else { 15 }),
809+
Some(Star::Bright) => {
810+
return (
811+
'*',
812+
if color {
813+
rgb_to_ansi(255, 255, 240)
814+
} else {
815+
15
816+
},
817+
)
818+
}
819+
Some(Star::Medium) => {
820+
return (
821+
'.',
822+
if color {
823+
rgb_to_ansi(180, 180, 180)
824+
} else {
825+
15
826+
},
827+
)
828+
}
829+
Some(Star::Dim) => {
830+
return (
831+
'·',
832+
if color {
833+
rgb_to_ansi(110, 110, 130)
834+
} else {
835+
15
836+
},
837+
)
838+
}
793839
None => {}
794840
}
795841
(' ', 16)
@@ -798,7 +844,9 @@ fn shade_ascii(
798844

799845
fn mix_rgb_u8(a: (u8, u8, u8), b: (u8, u8, u8), t: f64) -> (u8, u8, u8) {
800846
let lerp = |x: u8, y: u8| -> u8 {
801-
(x as f64 + (y as f64 - x as f64) * t).round().clamp(0.0, 255.0) as u8
847+
(x as f64 + (y as f64 - x as f64) * t)
848+
.round()
849+
.clamp(0.0, 255.0) as u8
802850
};
803851
(lerp(a.0, b.0), lerp(a.1, b.1), lerp(a.2, b.2))
804852
}
@@ -842,7 +890,9 @@ mod tests {
842890
use chrono::TimeZone;
843891

844892
fn now_fixed() -> DateTime<Utc> {
845-
Utc.with_ymd_and_hms(2026, 5, 15, 12, 0, 0).single().unwrap()
893+
Utc.with_ymd_and_hms(2026, 5, 15, 12, 0, 0)
894+
.single()
895+
.unwrap()
846896
}
847897

848898
#[test]
@@ -852,7 +902,10 @@ mod tests {
852902
assert!((app.camera.lon_deg - 16.37).abs() < 1e-9);
853903
assert_eq!(app.camera.distance, ZOOM_DEFAULT);
854904
assert_eq!(app.mode, RenderMode::Blocks);
855-
assert!(matches!(app.auto_rotation, AutoRotation::On { speed_idx: 0 }));
905+
assert!(matches!(
906+
app.auto_rotation,
907+
AutoRotation::On { speed_idx: 0 }
908+
));
856909
assert!(!app.freeze);
857910
}
858911

src/geo.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22
33
use chrono::{Local, Offset};
44

5-
use crate::constants::{
6-
HOME_DEFAULT_LAT_NORTH, HOME_FALLBACK_LAT, HOME_FALLBACK_LON,
7-
};
5+
use crate::constants::{HOME_DEFAULT_LAT_NORTH, HOME_FALLBACK_LAT, HOME_FALLBACK_LON};
86

97
/// `--home`/`-h` CLI-Argument parsen. Erwartet `"LAT,LON"` in Grad.
108
/// Toleriert Whitespace und gibt aussagekräftige Fehler zurück.

src/main.rs

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,28 @@ use anyhow::{Context, Result};
22
use chrono::{TimeZone, Utc};
33
use clap::Parser;
44
use crossterm::{
5-
cursor, event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
5+
cursor,
6+
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
67
execute,
78
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
89
};
9-
use std::io::{Write, stdout};
10+
use std::io::{stdout, Write};
1011
use std::time::{Duration, Instant};
1112

12-
use globe::app::{AppState, COARSE_LAT_STEP_DEG, COARSE_LON_STEP_DEG, RenderMode};
13-
use globe::config::{Cli, ModeArg, effective_mode};
14-
use globe::constants::{CELL_ASPECT_MAX, CELL_ASPECT_MIN, MIN_COLS, MIN_ROWS};
13+
use globe::app::{AppState, RenderMode, COARSE_LAT_STEP_DEG, COARSE_LON_STEP_DEG};
14+
use globe::config::{effective_mode, Cli, ModeArg};
15+
use globe::constants::{CELL_ASPECT_MAX, CELL_ASPECT_MIN, IDLE_FPS, MIN_COLS, MIN_ROWS};
1516
use globe::geo;
1617
use globe::tui::FrameBuffer;
1718

19+
/// Wenn die App nicht visuell animiert und seit dieser Zeit kein Input kam,
20+
/// drosseln wir die Frame-Rate auf `IDLE_FPS`.
21+
const IDLE_DEBOUNCE: Duration = Duration::from_secs(2);
22+
1823
fn main() -> Result<()> {
1924
let cli = Cli::parse();
20-
let home = geo::resolve_home(cli.home.as_deref())
21-
.map_err(|e| anyhow::anyhow!("--home: {}", e))?;
25+
let home =
26+
geo::resolve_home(cli.home.as_deref()).map_err(|e| anyhow::anyhow!("--home: {}", e))?;
2227
let mode = match effective_mode(cli.mode, cli.no_color) {
2328
ModeArg::Blocks => RenderMode::Blocks,
2429
ModeArg::Ascii => RenderMode::Ascii,
@@ -71,20 +76,32 @@ fn run_interactive(app: &mut AppState, fps: u32) -> Result<()> {
7176
let _guard = TerminalGuard;
7277

7378
let active_frame_dur = Duration::from_millis((1000 / fps).max(1) as u64);
79+
let idle_frame_dur = Duration::from_millis((1000 / IDLE_FPS.max(1)) as u64);
7480
let mut fb = FrameBuffer::new(0, 0);
7581
let mut last_step = Instant::now();
82+
let mut last_input = Instant::now();
7683

7784
loop {
78-
if event::poll(active_frame_dur)? {
85+
// Frame-Rate dynamisch: Idle (Auto-Rotation off oder Realtime + kein Input)
86+
// → langsam, sonst voll.
87+
let frame_dur = if app.is_idle() && last_input.elapsed() > IDLE_DEBOUNCE {
88+
idle_frame_dur
89+
} else {
90+
active_frame_dur
91+
};
92+
93+
if event::poll(frame_dur)? {
7994
let ev = event::read()?;
8095
if let Event::Key(k) = ev {
81-
if k.kind == KeyEventKind::Press
82-
&& dispatch_key(app, k.code, k.modifiers)
83-
{
84-
return Ok(());
96+
if k.kind == KeyEventKind::Press {
97+
last_input = Instant::now();
98+
if dispatch_key(app, k.code, k.modifiers) {
99+
return Ok(());
100+
}
85101
}
86102
} else if let Event::Resize(_, _) = ev {
87103
fb.force_full_redraw();
104+
last_input = Instant::now();
88105
}
89106
}
90107

src/moon.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use chrono::{DateTime, Utc};
44

55
use crate::sun::{julian_day, sun_direction};
6-
use crate::vec3::{V3, from_lat_lon};
6+
use crate::vec3::{from_lat_lon, V3};
77

88
const EARTH_RADIUS_KM: f64 = 6371.0;
99

@@ -38,8 +38,8 @@ pub fn position(now: DateTime<Utc>) -> SubLunar {
3838
let sin_decl =
3939
beta_rad.sin() * eps_rad.cos() + beta_rad.cos() * eps_rad.sin() * lambda_rad.sin();
4040
let decl_rad = sin_decl.clamp(-1.0, 1.0).asin();
41-
let alpha_rad = (lambda_rad.sin() * eps_rad.cos() - beta_rad.tan() * eps_rad.sin())
42-
.atan2(lambda_rad.cos());
41+
let alpha_rad =
42+
(lambda_rad.sin() * eps_rad.cos() - beta_rad.tan() * eps_rad.sin()).atan2(lambda_rad.cos());
4343
let alpha_deg = alpha_rad.to_degrees().rem_euclid(360.0);
4444

4545
// GMST → Sublunar-Lon (gleiche Konvention wie sun::subsolar_point)

src/render.rs

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use crate::camera::Camera;
44
use crate::constants::{CLOUD_RADIUS, FOV_HALF, GLOW_OUTER, STAR_DENSITY, SUN_MOON_VISIBLE_DIST};
55
use crate::moon;
66
use crate::sun;
7-
use crate::vec3::{V3, from_lat_lon, rotate_y, to_lat_lon};
7+
use crate::vec3::{from_lat_lon, rotate_y, to_lat_lon, V3};
88

99
// `rotate_y` wird auch für sun/moon-Marker (Erd-fixed → Welt-Frame) gebraucht.
1010
use crate::world::{self, Class};
@@ -62,26 +62,14 @@ pub fn ray_perp_to_origin(ray: &Ray) -> f64 {
6262

6363
/// Erzeugt Strahl für ein Sub-Pixel.
6464
/// `sx` in [0, width), `sy` in [0, sub_height). Sample wird zentriert (+0.5).
65-
pub fn ray_for_sub_pixel(
66-
sx: f64,
67-
sy: f64,
68-
width: f64,
69-
sub_height: f64,
70-
cam: &Camera,
71-
) -> Ray {
65+
pub fn ray_for_sub_pixel(sx: f64, sy: f64, width: f64, sub_height: f64, cam: &Camera) -> Ray {
7266
ray_at_screen(sx + 0.5, sy + 0.5, width, sub_height, cam)
7367
}
7468

7569
/// Variante ohne +0.5-Center-Offset: `sx`/`sy` werden direkt als Position
7670
/// interpretiert. Praktisch wenn die Caller-Mathematik nicht-integer-Sample-
7771
/// Positionen erzeugt (z. B. Halbblock-Sub-Pixel bei nicht-1:2-Cell-Aspect).
78-
pub fn ray_at_screen(
79-
sx: f64,
80-
sy: f64,
81-
width: f64,
82-
sub_height: f64,
83-
cam: &Camera,
84-
) -> Ray {
72+
pub fn ray_at_screen(sx: f64, sy: f64, width: f64, sub_height: f64, cam: &Camera) -> Ray {
8573
let aspect = width / sub_height;
8674
let tan_fov = FOV_HALF.tan();
8775
let u = (2.0 * sx / width - 1.0) * aspect * tan_fov;
@@ -174,7 +162,9 @@ pub fn palette_night(c: Class) -> (u8, u8, u8) {
174162

175163
fn mix_rgb(a: (u8, u8, u8), b: (u8, u8, u8), t: f64) -> (u8, u8, u8) {
176164
let lerp = |x: u8, y: u8| -> u8 {
177-
(x as f64 + (y as f64 - x as f64) * t).round().clamp(0.0, 255.0) as u8
165+
(x as f64 + (y as f64 - x as f64) * t)
166+
.round()
167+
.clamp(0.0, 255.0) as u8
178168
};
179169
(lerp(a.0, b.0), lerp(a.1, b.1), lerp(a.2, b.2))
180170
}
@@ -233,7 +223,9 @@ pub fn star_at(x: u32, y: u32) -> Option<Star> {
233223
/// Hash mit zwei Misch-Schritten — der alte XOR-Ansatz erzeugte sichtbare
234224
/// Diagonalen, weil x und y in benachbarten Zellen quasi-linear korrelieren.
235225
fn hash_u32(x: u32, y: u32) -> f64 {
236-
let mut v = x.wrapping_mul(2_654_435_761).wrapping_add(y.wrapping_mul(1_597_334_677));
226+
let mut v = x
227+
.wrapping_mul(2_654_435_761)
228+
.wrapping_add(y.wrapping_mul(1_597_334_677));
237229
v ^= v >> 16;
238230
v = v.wrapping_mul(0x85eb_ca6b);
239231
v ^= v >> 13;

src/sun.rs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,15 @@
22
33
use chrono::{DateTime, Datelike, Timelike, Utc};
44

5-
use crate::vec3::{V3, from_lat_lon};
5+
use crate::vec3::{from_lat_lon, V3};
66

77
/// Julianisches Datum für UTC-Zeitpunkt (Meeus Algorithmus).
88
pub fn julian_day(now: DateTime<Utc>) -> f64 {
99
let mut y = now.year();
1010
let mut m = now.month() as i32;
1111
let d = now.day() as f64;
12-
let day_frac = (now.hour() as f64
13-
+ now.minute() as f64 / 60.0
14-
+ now.second() as f64 / 3600.0)
15-
/ 24.0;
12+
let day_frac =
13+
(now.hour() as f64 + now.minute() as f64 / 60.0 + now.second() as f64 / 3600.0) / 24.0;
1614

1715
if m <= 2 {
1816
y -= 1;
@@ -36,8 +34,7 @@ pub fn subsolar_point(now: DateTime<Utc>) -> (f64, f64) {
3634

3735
let l_deg = (280.460 + 0.9856474 * n).rem_euclid(360.0);
3836
let g_rad = ((357.528 + 0.9856003 * n).rem_euclid(360.0)).to_radians();
39-
let lambda_rad =
40-
(l_deg + 1.915 * g_rad.sin() + 0.020 * (2.0 * g_rad).sin()).to_radians();
37+
let lambda_rad = (l_deg + 1.915 * g_rad.sin() + 0.020 * (2.0 * g_rad).sin()).to_radians();
4138
let epsilon_rad = (23.439 - 0.0000004 * n).to_radians();
4239

4340
let decl_deg = (epsilon_rad.sin() * lambda_rad.sin()).asin().to_degrees();

src/tui.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,12 @@ impl FrameBuffer {
4242
}
4343
}
4444

45-
pub fn cols(&self) -> usize { self.cols }
46-
pub fn rows(&self) -> usize { self.rows }
45+
pub fn cols(&self) -> usize {
46+
self.cols
47+
}
48+
pub fn rows(&self) -> usize {
49+
self.rows
50+
}
4751

4852
pub fn resize(&mut self, cols: usize, rows: usize) {
4953
if cols == self.cols && rows == self.rows {
@@ -221,7 +225,11 @@ mod tests {
221225
fb.flush_diff(&mut out2).unwrap();
222226
let s2 = String::from_utf8(out2).unwrap();
223227
assert!(s2.contains('C'));
224-
assert!(!s2.contains('A'), "unchanged A should not be re-emitted: {:?}", s2);
228+
assert!(
229+
!s2.contains('A'),
230+
"unchanged A should not be re-emitted: {:?}",
231+
s2
232+
);
225233
}
226234

227235
#[test]

0 commit comments

Comments
 (0)