summaryrefslogtreecommitdiff
path: root/crates/gipfel-app/src/main.rs
diff options
context:
space:
mode:
authorJohannes Hofmann <Johannes.Hofmann@gmx.de>2026-10-26 20:05:20 +0100
committerJohannes Hofmann <Johannes.Hofmann@gmx.de>2026-10-26 20:05:20 +0100
commit375d87a9b0ec1cf2fffd705adb870c86144d2400 (patch)
tree1e8e05e7cd58686628d0f4622622456613a42e4d /crates/gipfel-app/src/main.rs
parent45425dd047b791fe63f13a46a3251c529cfffc94 (diff)
Replace the C++ implementation with the Rust port
The Rust port (formerly gipfel-rs) is now the official successor to the FLTK/GSL C++ code base. This squashes the port into a single commit and removes the C++ sources (they remain in the git history below). Layout: crates/gipfel-core headless core: hills, panorama geometry, projections (maxima-generated derivatives), pure-Rust LM fit, gipfel: JPEG comment + EXIF I/O, profiles, stitching crates/gipfel-cli command-line tools (export, info, stitch, profiles) crates/gipfel-app Cosmic DE (libcosmic) GUI, binary name `gipfel` codegen/ maxima scripts + regen.sh for the projection code tests/ golden files + C++ reference harnesses (historical) The original README is kept for now; it will be updated to describe the Rust build. gipfel.dat, AUTHORS, ChangeLog and NEWS are unchanged.
Diffstat (limited to 'crates/gipfel-app/src/main.rs')
-rw-r--r--crates/gipfel-app/src/main.rs1805
1 files changed, 1805 insertions, 0 deletions
diff --git a/crates/gipfel-app/src/main.rs b/crates/gipfel-app/src/main.rs
new file mode 100644
index 0000000..4cf0c63
--- /dev/null
+++ b/crates/gipfel-app/src/main.rs
@@ -0,0 +1,1805 @@
+//! gipfel for the COSMIC desktop: annotate mountain panoramas.
+//!
+//! M2/M3: full app skeleton (menus, dialogs, settings sidebar) plus the
+//! interactive viewport (marker placement, refit, context menu, track).
+
+mod compass;
+mod dump;
+mod viewport;
+
+use std::marker::PhantomData;
+use std::path::{Path, PathBuf};
+use std::rc::Rc;
+
+use cosmic::app::{Core, Task};
+use cosmic::iced::keyboard::{self, Key};
+use cosmic::iced::window::Event as WinEvent;
+use cosmic::iced::{event, Color, Event, Length, Subscription, Vector};
+use cosmic::widget::{self, menu, menu::action::MenuAction};
+use cosmic::{Application, ApplicationExt, Element, Theme};
+
+use gipfel_core::hill::{self, Hill, FLAG_DUPLIC, FLAG_HIDDEN, FLAG_TRACK_POINT, FLAG_VISIBLE};
+use gipfel_core::metadata::ImageMetaData;
+use gipfel_core::panorama::Panorama;
+use gipfel_core::projection::ProjectionType;
+use gipfel_core::stitch;
+use gipfel_core::track;
+
+use compass::{Compass, CompassStyle, STRIP_H};
+
+use viewport::{Marker, TrackPt, ViewportWidget, VpMsg};
+
+/// actions of the right-click popover menu on the viewport.
+/// The click position / hit index are baked into the action at menu-build
+/// time (like cosmic-files bakes its entity), so the handler never depends
+/// on state that may have changed while the menu was open.
+#[derive(Clone, Copy, PartialEq)]
+pub enum CtxAction {
+ /// image-space position of the right-click
+ FindPeak(f64, f64),
+ /// hill index of the clicked marker
+ ToggleHidden(usize),
+}
+
+impl Eq for CtxAction {}
+
+impl MenuAction for CtxAction {
+ type Message = Message;
+
+ fn message(&self) -> Message {
+ match self {
+ CtxAction::FindPeak(x, y) => Message::ContextFindPeak(*x, *y),
+ CtxAction::ToggleHidden(i) => Message::ContextToggleHidden(*i),
+ }
+ }
+}
+
+pub struct Flags {
+ pub data: Option<PathBuf>,
+ pub image: Option<PathBuf>,
+}
+
+pub struct Gipfel {
+ core: Core,
+ pan: Panorama,
+ project: Option<Project>,
+ /// hill indices of the known (flagged) markers
+ known: Vec<usize>,
+ /// hill indices of the GPS track points, in file order
+ track: Vec<usize>,
+ // viewport state
+ zoom: f32,
+ fit: bool,
+ panv: Vector,
+ hover: Option<usize>,
+ ctrl: bool,
+ version: u64,
+ win_w: f64,
+ win_h: f64,
+ vp_w: f64,
+ vp_h: f64,
+ // sidebar state
+ lat_in: String,
+ lon_in: String,
+ height_in: String,
+ k0_in: String,
+ k1_in: String,
+ x0_in: String,
+ profile_in: String,
+ profiles: Vec<String>,
+ profile_overwrite: bool,
+ track_width: f64,
+ show_hidden: bool,
+ fit_distortion: bool,
+ label_font: f32,
+ compass_style: CompassStyle,
+ status: String,
+ // overlays
+ picker: Option<Picker>,
+ context: Option<ContextState>,
+ /// peak info shown in the status bar while the mouse hovers a marker
+ hover_info: Option<String>,
+ about: bool,
+}
+
+struct Project {
+ path: PathBuf,
+ handle: cosmic::widget::image::Handle,
+ w: u32,
+ h: u32,
+ md: ImageMetaData,
+}
+
+struct Picker {
+ title: String,
+ query: String,
+ mode: PickerMode,
+}
+
+#[derive(Clone, Copy)]
+enum PickerMode {
+ Viewpoint,
+ FindPeak { img_x: f64, img_y: f64 },
+}
+
+struct ContextState {
+ img_x: f64,
+ img_y: f64,
+ hit: Option<usize>,
+}
+
+#[derive(Debug, Clone)]
+pub enum Message {
+ Vp(VpMsg),
+ Event(Event),
+ WinResized(f64, f64),
+ FileDropped(Vec<PathBuf>),
+
+ OpenImage,
+ OpenPath(Option<PathBuf>),
+ ImageLoaded(Box<Result<LoadedImage, String>>),
+ SaveImage,
+ SavePath(Option<PathBuf>),
+ ImageSaved(Option<PathBuf>),
+ DumpImage,
+ ImageDumped(Box<Result<PathBuf, String>>),
+ StitchTiff,
+ StitchDone(Box<Result<PathBuf, String>>),
+ LoadTrack,
+ TrackLoaded(Box<Result<Vec<Hill>, String>>),
+
+ ChooseViewpoint,
+ PickerQuery(String),
+ PickerPick(usize),
+ PickerClose,
+ ContextFindPeak(f64, f64),
+ ContextToggleHidden(usize),
+ Surface(cosmic::surface::Action<Message>),
+ Redraw,
+
+ Center(f64),
+ Pitch(f64),
+ Roll(f64),
+ LabelFont(f32),
+ Focal(f64),
+ Visibility(f64),
+ TrackWidth(f64),
+ Lat(String),
+ Lon(String),
+ Height(String),
+ ViewApply,
+ K0(String),
+ K1(String),
+ X0(String),
+ DistortionApply,
+ ProfileName(String),
+ ProfileSave,
+ ProfileLoad,
+ ProfileSelect(usize),
+ Projection(ProjectionType),
+ ToggleShowHidden,
+ FitDistortion(bool),
+ ToggleNav,
+ CompassStyle(CompassStyle),
+
+ Fit,
+ ZoomIn,
+ ZoomOut,
+ About(bool),
+ Quit,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum Action {
+ Open,
+ Save,
+ Dump,
+ Stitch,
+ LoadTrack,
+ Quit,
+ ChooseViewpoint,
+ ShowHidden,
+ Fit,
+ ZoomIn,
+ ZoomOut,
+ ProjNormal,
+ ProjPanoramic,
+ CompassAligned,
+ CompassRuler,
+ CompassOff,
+ About,
+}
+
+impl MenuAction for Action {
+ type Message = Message;
+ fn message(&self) -> Message {
+ match self {
+ Action::Open => Message::OpenImage,
+ Action::Save => Message::SaveImage,
+ Action::Dump => Message::DumpImage,
+ Action::Stitch => Message::StitchTiff,
+ Action::LoadTrack => Message::LoadTrack,
+ Action::Quit => Message::Quit,
+ Action::ChooseViewpoint => Message::ChooseViewpoint,
+ Action::ShowHidden => Message::ToggleShowHidden,
+ Action::Fit => Message::Fit,
+ Action::ZoomIn => Message::ZoomIn,
+ Action::ZoomOut => Message::ZoomOut,
+ Action::ProjNormal => Message::Projection(ProjectionType::Rectilinear),
+ Action::ProjPanoramic => Message::Projection(ProjectionType::Cylindrical),
+ Action::CompassAligned => Message::CompassStyle(CompassStyle::Aligned),
+ Action::CompassRuler => Message::CompassStyle(CompassStyle::Ruler),
+ Action::CompassOff => Message::CompassStyle(CompassStyle::Off),
+ Action::About => Message::About(true),
+ }
+ }
+}
+
+impl From<VpMsg> for Message {
+ fn from(m: VpMsg) -> Self {
+ Message::Vp(m)
+ }
+}
+
+#[derive(Clone)]
+pub struct LoadedImage {
+ path: PathBuf,
+ w: u32,
+ h: u32,
+ handle: cosmic::widget::image::Handle,
+ md: ImageMetaData,
+}
+
+impl std::fmt::Debug for LoadedImage {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "LoadedImage({})", self.path.display())
+ }
+}
+
+/// Load a GPS track from a `.gpx` (GPS Exchange) or `.csv`/`.dat` (gipfel
+/// hill-CSV) file, returning hills flagged as track points.
+fn load_track_file(path: &Path) -> Result<Vec<Hill>, String> {
+ if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("gpx")) {
+ track::load_gpx(&path.to_string_lossy(), 2000)
+ } else {
+ let mut hs = hill::load(&path.to_string_lossy()).map_err(|e| e.to_string())?;
+ for h in hs.iter_mut() {
+ h.flags |= FLAG_TRACK_POINT;
+ }
+ Ok(hs)
+ }
+}
+
+fn data_candidates(flag: Option<&Path>) -> Vec<PathBuf> {
+ let mut v = Vec::new();
+ if let Some(p) = flag {
+ v.push(p.to_path_buf());
+ }
+ if let Ok(e) = std::env::var("GIPFEL_DATA") {
+ v.push(PathBuf::from(e));
+ }
+ v.push(PathBuf::from("gipfel.dat"));
+ if let Some(home) = std::env::var_os("HOME") {
+ v.push(PathBuf::from(home).join(".local/share/gipfel/gipfel.dat"));
+ }
+ v.push(PathBuf::from("/usr/local/share/gipfel/gipfel.dat"));
+ v.push(PathBuf::from("/usr/share/gipfel/gipfel.dat"));
+ v
+}
+
+/// Inverse projection for the vertical axis: the elevation whose projected
+/// pixel row (image-centered coords, center column) is `y_img`. Shares the
+/// damped Newton iteration with the compass strip (`compass::newton_inverse`).
+fn elev_at_img_y(pan: &Panorama, y_img: f64) -> f64 {
+ let c = pan.parms.a_center;
+ let p = pan.parms.a_pitch;
+ let seed = p - (y_img / pan.parms.scale).atan();
+ compass::newton_inverse(
+ y_img,
+ |e| pan.project_point(c, e).map(|v| v.1),
+ seed,
+ p - 1.535,
+ p + 1.535,
+ )
+}
+
+impl Gipfel {
+ fn bump(&mut self) {
+ // keep the visibility limit in sync with the lens' actual field of
+ // view: the angle at which the (distorted) projection reaches the
+ // image edge. Directions beyond it cannot be inside the photo, even
+ // if the distortion model folds their projection back into it.
+ let limit = self.view_angle_limit();
+ if (limit - self.pan.max_view_angle).abs() > 1e-9 {
+ self.pan.max_view_angle = limit;
+ // keep the user-placed marker positions (observations) intact
+ self.pan.update_visible_mountains(Some(&self.known));
+ }
+ self.version += 1;
+ }
+
+ /// Reload the stored profile names from the prefs file.
+ fn refresh_profiles(&mut self) {
+ self.profiles = gipfel_core::profile::list().into_iter().map(|(n, _)| n).collect();
+ }
+
+ /// Load a stored profile and apply its distortion parameters.
+ fn apply_profile(&mut self, name: &str) {
+ match gipfel_core::profile::load(name) {
+ Ok(p) => {
+ self.pan.set_distortion_params(p.k0, p.k1, p.x0);
+ self.sync_inputs();
+ self.bump();
+ self.status = format!("loaded profile '{name}'");
+ }
+ Err(e) => self.status = format!("profile '{name}': {e}"),
+ }
+ }
+
+ /// half-angle (radians) the projection maps the image's right edge to,
+ /// i.e. the horizontal FOV of the lens including fitted distortion.
+ /// 0 = no image loaded / not computable.
+ fn view_angle_limit(&self) -> f64 {
+ let Some(p) = &self.project else {
+ return 0.0;
+ };
+ let proj = gipfel_core::projection::projection_from_type(self.pan.projection_type);
+ let c = self.pan.parms.a_center;
+ let a_edge = compass::alph_at_img_x(
+ self.pan.projection_type,
+ &*proj,
+ &self.pan.parms,
+ p.w as f64 / 2.0,
+ c,
+ );
+ (a_edge - c).abs()
+ }
+
+ fn img_max(&self) -> f64 {
+ self.project
+ .as_ref()
+ .map(|p| p.w.max(p.h) as f64)
+ .unwrap_or(1000.0)
+ }
+
+ fn focal_35mm(&self) -> f64 {
+ self.pan.get_scale() * 35.0 / self.img_max()
+ }
+
+ fn set_focal_35mm(&mut self, f: f64) {
+ self.pan.set_scale(f * self.img_max() / 35.0);
+ }
+
+ fn sync_inputs(&mut self) {
+ self.lat_in = format!("{:.5}", self.pan.get_view_lat());
+ self.lon_in = format!("{:.5}", self.pan.get_view_long());
+ self.height_in = format!("{:.0}", self.pan.get_view_height());
+ self.k0_in = format!("{:.5}", self.pan.parms.k0);
+ self.k1_in = format!("{:.5}", self.pan.parms.k1);
+ self.x0_in = format!("{:.1}", self.pan.parms.x0);
+ }
+
+ fn fit_zoom(&self) -> f32 {
+ let p = match &self.project {
+ Some(p) => p,
+ None => return 1.0,
+ };
+ if self.vp_w < 10.0 || self.vp_h < 10.0 {
+ return 1.0;
+ }
+ (self.vp_w / p.w as f64).min(self.vp_h / p.h as f64) as f32
+ }
+
+ fn markers(&self) -> Rc<Vec<Marker>> {
+ let mut v = Vec::new();
+ for &i in &self.pan.visible {
+ let h = &self.pan.hills[i];
+ if h.is_track_point() || h.flags & FLAG_DUPLIC != 0 {
+ continue;
+ }
+ let hidden = h.flags & FLAG_HIDDEN != 0;
+ if hidden && !self.show_hidden {
+ continue;
+ }
+ v.push(Marker {
+ x: h.x,
+ y: h.y,
+ name: h.name.as_str().into(),
+ idx: i,
+ known: self.known.contains(&i),
+ hidden,
+ });
+ }
+ Rc::new(v)
+ }
+
+ fn track_pts(&self) -> Rc<Vec<TrackPt>> {
+ let v = self
+ .track
+ .iter()
+ .filter(|&&i| self.pan.hills[i].flags & FLAG_VISIBLE != 0)
+ .map(|&i| {
+ let h = &self.pan.hills[i];
+ // C: max(scale * track_width / (dist_m * 10), 1) - distance in
+ // meters. (Was wrongly divided by km here, inflating widths
+ // 1000x and breaking stroke triangulation.) Clamped high so
+ // near-camera points can't produce absurd strokes.
+ let dist_m = self.pan.get_real_distance(i).max(1.0);
+ let width =
+ (self.pan.get_scale() * self.track_width / (dist_m * 10.0)).clamp(1.0, 1000.0);
+ TrackPt {
+ x: h.x,
+ y: h.y,
+ hidden: h.flags & FLAG_HIDDEN != 0,
+ width,
+ }
+ })
+ .collect();
+ Rc::new(v)
+ }
+
+ /// recompute viewport metrics from window size, nav and compass state
+ fn remeasure(&mut self) {
+ let nav = if self.core().nav_bar_active() { 340.0 } else { 0.0 };
+ let strip = if self.project.is_some() && self.compass_style != CompassStyle::Off {
+ STRIP_H as f64
+ } else {
+ 0.0
+ };
+ self.vp_w = (self.win_w - nav).max(100.0);
+ self.vp_h = (self.win_h - 100.0 - strip).max(100.0);
+ if self.fit {
+ self.zoom = self.fit_zoom();
+ }
+ }
+
+ fn refit(&mut self) {
+ if self.known.is_empty() {
+ return;
+ }
+ if let Err(e) = self.pan.comp_params(&self.known, self.fit_distortion) {
+ self.status = format!("refit failed: {e}");
+ }
+ self.sync_inputs();
+ self.bump();
+ }
+
+ fn md_from_state(&self) -> ImageMetaData {
+ let mut md = ImageMetaData::new();
+ if let Some(p) = &self.project {
+ md.manufacturer = p.md.manufacturer.clone();
+ md.model = p.md.model.clone();
+ }
+ md.longitude = self.pan.get_view_long();
+ md.latitude = self.pan.get_view_lat();
+ md.height = self.pan.get_view_height();
+ md.direction = self.pan.get_center_angle();
+ md.pitch = self.pan.get_pitch_angle();
+ md.roll = self.pan.get_roll_angle();
+ md.focal_length_35mm = self.focal_35mm();
+ md.projection_type = self.pan.projection_type as i32;
+ md.k0 = self.pan.parms.k0;
+ md.k1 = self.pan.parms.k1;
+ md.x0 = self.pan.parms.x0;
+ md
+ }
+
+ fn open_image(&mut self, path: PathBuf) -> Task<Message> {
+ let task = async move {
+ let img = image::open(&path).map_err(|e| format!("{}: {e}", path.display()))?;
+ let (w, h) = (img.width(), img.height());
+ let rgba = img.to_rgba8();
+ let handle = cosmic::widget::image::Handle::from_rgba(w, h, rgba.into_raw());
+ let mut md = ImageMetaData::new();
+ md.load_image(&path.to_string_lossy());
+ Ok(LoadedImage { path, w, h, handle, md })
+ };
+ cosmic::task::future(async move { Message::ImageLoaded(Box::new(task.await)) })
+ }
+
+ fn picker_entries(&self, p: &Picker) -> Vec<(String, usize)> {
+ let mut idxs: Vec<usize> = match p.mode {
+ PickerMode::Viewpoint => (0..self.pan.hills.len()).collect(),
+ PickerMode::FindPeak { .. } => self.pan.close.clone(),
+ };
+ let q = p.query.to_uppercase();
+ let mut entries: Vec<(String, usize)> = idxs
+ .drain(..)
+ .filter(|&i| {
+ let h = &self.pan.hills[i];
+ h.flags & (FLAG_DUPLIC | FLAG_TRACK_POINT) == 0
+ && (q.is_empty() || h.name.to_uppercase().contains(&q))
+ })
+ .map(|i| (format!("{} ({} m)", self.pan.hills[i].name, self.pan.hills[i].height as i64), i))
+ .collect();
+ entries.sort_by(|a, b| a.0.cmp(&b.0));
+ entries.truncate(300);
+ entries
+ }
+
+ fn update_vp(&mut self, msg: VpMsg) -> Task<Message> {
+ match msg {
+ VpMsg::ZoomAt { factor, img } => {
+ let new_zoom = (self.zoom * factor).clamp(0.02, 16.0);
+ let z = self.zoom as f64;
+ let nz = new_zoom as f64;
+ self.panv = Vector::new(
+ self.panv.x + (img.x as f64 * (z - nz)) as f32,
+ self.panv.y + (img.y as f64 * (z - nz)) as f32,
+ );
+ self.zoom = new_zoom;
+ self.fit = false;
+ }
+ VpMsg::PanBy { delta } => {
+ self.panv = Vector::new(self.panv.x + delta.x, self.panv.y + delta.y);
+ self.fit = false;
+ }
+ VpMsg::PanTo { pan } => {
+ self.panv = pan;
+ self.fit = false;
+ }
+ VpMsg::MoveMarker { idx, img } => {
+ if idx < self.pan.hills.len() {
+ self.pan.hills[idx].x = img.x as f64;
+ self.pan.hills[idx].y = img.y as f64;
+ self.bump();
+ }
+ }
+ VpMsg::DragEnd { had_marker } => {
+ if had_marker {
+ self.refit();
+ }
+ }
+ VpMsg::ToggleKnown(hit) => {
+ if let Some(i) = hit {
+ if let Some(pos) = self.known.iter().position(|&k| k == i) {
+ self.known.remove(pos);
+ } else {
+ self.known.push(i);
+ }
+ self.refit();
+ }
+ }
+ VpMsg::ContextAt { img, hit } => {
+ self.context = Some(ContextState { img_x: img.x as f64, img_y: img.y as f64, hit });
+ }
+ VpMsg::Hover(h) => {
+ if h != self.hover {
+ self.hover = h;
+ self.hover_info = h.map(|i| {
+ let hm = &self.pan.hills[i];
+ format!(
+ "{} ({} m), distance {:.2} km",
+ hm.name,
+ hm.height as i64,
+ self.pan.get_real_distance(i) / 1000.0
+ )
+ });
+ }
+ }
+ }
+ Task::none()
+ }
+}
+
+impl Application for Gipfel {
+ type Executor = cosmic::executor::Default;
+ type Flags = Flags;
+ type Message = Message;
+ const APP_ID: &'static str = "org.gipfel.App";
+
+ fn core(&self) -> &Core {
+ &self.core
+ }
+
+ fn core_mut(&mut self) -> &mut Core {
+ &mut self.core
+ }
+
+ fn init(core: Core, flags: Flags) -> (Self, Task<Message>) {
+ let mut pan = Panorama::new();
+ let mut status = String::from("no hill database loaded");
+ for c in data_candidates(flags.data.as_deref()) {
+ if c.is_file() {
+ match pan.load_data(&c.to_string_lossy()) {
+ Ok(()) => {
+ status = format!("{} hills loaded", pan.hills.len());
+ break;
+ }
+ Err(e) => status = format!("cannot read {}: {e}", c.display()),
+ }
+ }
+ }
+
+ let mut app = Gipfel {
+ core,
+ pan,
+ project: None,
+ known: Vec::new(),
+ track: Vec::new(),
+ zoom: 1.0,
+ fit: true,
+ panv: Vector::ZERO,
+ hover: None,
+ ctrl: false,
+ version: 0,
+ win_w: 1280.0,
+ win_h: 800.0,
+ vp_w: 0.0,
+ vp_h: 0.0,
+ lat_in: String::new(),
+ lon_in: String::new(),
+ height_in: String::new(),
+ k0_in: String::new(),
+ k1_in: String::new(),
+ x0_in: String::new(),
+ profile_in: String::new(),
+ profiles: gipfel_core::profile::list().into_iter().map(|(n, _)| n).collect(),
+ profile_overwrite: false,
+ track_width: 200.0,
+ show_hidden: false,
+ label_font: viewport::DEFAULT_LABEL_FONT,
+ fit_distortion: false,
+ compass_style: CompassStyle::Aligned,
+ status,
+ picker: None,
+ context: None,
+ hover_info: None,
+ about: false,
+ };
+ app.sync_inputs();
+
+ let task = match flags.image {
+ Some(p) => app.open_image(p),
+ None => Task::none(),
+ };
+ (app, task)
+ }
+
+ fn header_start(&self) -> Vec<Element<'_, Message>> {
+ let mut kb = std::collections::HashMap::new();
+ use cosmic::widget::menu::key_bind::{KeyBind, Modifier};
+ let kb = &mut kb;
+ for (ch, action) in [
+ ("o", Action::Open),
+ ("s", Action::Save),
+ ("d", Action::Dump),
+ ("t", Action::LoadTrack),
+ ("v", Action::ChooseViewpoint),
+ ("f", Action::Fit),
+ ("q", Action::Quit),
+ ] {
+ kb.insert(
+ KeyBind { modifiers: vec![Modifier::Ctrl], key: Key::Character(ch.into()) },
+ action,
+ );
+ }
+
+ let file = menu::Tree::with_children(
+ cosmic::widget::RcElementWrapper::new(menu::root("File").into()),
+ menu::items(
+ kb,
+ vec![
+ menu::Item::Button("Open Image…", None, Action::Open),
+ menu::Item::Button("Save Image…", None, Action::Save),
+ menu::Item::Button("Screen Dump…", None, Action::Dump),
+ menu::Item::Button("Stitch Cylindrical TIFF…", None, Action::Stitch),
+ menu::Item::Divider,
+ menu::Item::Button("Load GPX Track…", None, Action::LoadTrack),
+ menu::Item::Divider,
+ menu::Item::Button("Quit", None, Action::Quit),
+ ],
+ ),
+ );
+ let view = menu::Tree::with_children(
+ cosmic::widget::RcElementWrapper::new(menu::root("View").into()),
+ menu::items(
+ kb,
+ vec![
+ menu::Item::Button("Choose Viewpoint…", None, Action::ChooseViewpoint),
+ menu::Item::CheckBox(
+ "Show Hidden",
+ None,
+ self.show_hidden,
+ Action::ShowHidden,
+ ),
+ menu::Item::Divider,
+ menu::Item::Button("Zoom In", None, Action::ZoomIn),
+ menu::Item::Button("Zoom Out", None, Action::ZoomOut),
+ menu::Item::Button("Fit", None, Action::Fit),
+ menu::Item::Divider,
+ menu::Item::CheckBox(
+ "Compass strip",
+ None,
+ self.compass_style == CompassStyle::Aligned,
+ Action::CompassAligned,
+ ),
+ menu::Item::CheckBox(
+ "Compass ruler",
+ None,
+ self.compass_style == CompassStyle::Ruler,
+ Action::CompassRuler,
+ ),
+ menu::Item::CheckBox(
+ "Compass off",
+ None,
+ self.compass_style == CompassStyle::Off,
+ Action::CompassOff,
+ ),
+ ],
+ ),
+ );
+ let projection = menu::Tree::with_children(
+ cosmic::widget::RcElementWrapper::new(menu::root("Projection").into()),
+ menu::items(
+ kb,
+ vec![
+ menu::Item::CheckBox(
+ "Normal",
+ None,
+ self.pan.projection_type == ProjectionType::Rectilinear,
+ Action::ProjNormal,
+ ),
+ menu::Item::CheckBox(
+ "Panoramic",
+ None,
+ self.pan.projection_type == ProjectionType::Cylindrical,
+ Action::ProjPanoramic,
+ ),
+ ],
+ ),
+ );
+ let help = menu::Tree::with_children(
+ cosmic::widget::RcElementWrapper::new(menu::root("Help").into()),
+ menu::items(kb, vec![menu::Item::Button("About gipfel", None, Action::About)]),
+ );
+
+ vec![
+ widget::nav_bar_toggle()
+ .active(self.core().nav_bar_active())
+ .on_toggle(Message::ToggleNav)
+ .into(),
+ menu::bar(vec![file, view, projection, help])
+ .item_height(menu::ItemHeight::Dynamic(40))
+ .item_width(menu::ItemWidth::Uniform(260))
+ .spacing(4.0)
+ .into(),
+ ]
+ }
+
+ fn subscription(&self) -> Subscription<Message> {
+ event::listen_with(|event, _status, _id| match event {
+ Event::Keyboard(keyboard::Event::ModifiersChanged(m)) => {
+ Some(Message::Event(Event::Keyboard(keyboard::Event::ModifiersChanged(m))))
+ }
+ Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
+ if modifiers.control() {
+ if let Key::Character(c) = &key {
+ match c.as_str() {
+ "o" | "O" => return Some(Message::OpenImage),
+ "s" | "S" => return Some(Message::SaveImage),
+ "d" | "D" => return Some(Message::DumpImage),
+ "t" | "T" => return Some(Message::LoadTrack),
+ "v" | "V" => return Some(Message::ChooseViewpoint),
+ "f" | "F" => return Some(Message::Fit),
+ "q" | "Q" => return Some(Message::Quit),
+ _ => {}
+ }
+ }
+ }
+ match key {
+ Key::Named(cosmic::iced::keyboard::key::Named::Escape) => {
+ Some(Message::PickerClose)
+ }
+ _ => None,
+ }
+ }
+ Event::Window(WinEvent::FileDropped(paths)) => Some(Message::FileDropped(paths)),
+ Event::Window(WinEvent::Resized(size)) => {
+ Some(Message::WinResized(size.width as f64, size.height as f64))
+ }
+ _ => None,
+ })
+ }
+
+ fn update(&mut self, message: Message) -> Task<Message> {
+ use cosmic::dialog::file_chooser::FileFilter;
+
+ match message {
+ Message::Vp(m) => return self.update_vp(m),
+ Message::Event(Event::Keyboard(keyboard::Event::ModifiersChanged(m))) => {
+ self.ctrl = m.control();
+ }
+ Message::Event(_) => {}
+ Message::ToggleNav => {
+ self.core_mut().nav_bar_toggle();
+ self.remeasure();
+ }
+ Message::WinResized(w, h) => {
+ self.win_w = w;
+ self.win_h = h;
+ self.remeasure();
+ }
+ Message::FileDropped(paths) => {
+ for p in paths {
+ let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
+ if matches!(ext.to_ascii_lowercase().as_str(), "jpg" | "jpeg" | "png") {
+ return self.open_image(p);
+ } else if ext.eq_ignore_ascii_case("csv") || ext.eq_ignore_ascii_case("dat") {
+ // treat as track file
+ let task = async move {
+ hill::load(&p.to_string_lossy())
+ .map(|mut hs| {
+ for h in hs.iter_mut() {
+ h.flags |= FLAG_TRACK_POINT;
+ }
+ hs
+ })
+ .map_err(|e| e.to_string())
+ };
+ return cosmic::task::future(async move {
+ Message::TrackLoaded(Box::new(task.await))
+ });
+ }
+ }
+ }
+ Message::OpenImage => {
+ return cosmic::task::future(async move {
+ let filter =
+ FileFilter::new("Images").glob("*.jpg").glob("*.jpeg").glob("*.png");
+ Message::OpenPath(pick_open("Open Image", filter).await)
+ });
+ }
+ Message::OpenPath(Some(p)) => return self.open_image(p),
+ Message::OpenPath(None) => {}
+ Message::ImageLoaded(res) => match *res {
+ Ok(li) => {
+ let title = li.path.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
+ self.set_header_title(title);
+ let (w, h) = (li.w, li.h);
+ self.project = Some(Project {
+ path: li.path,
+ handle: li.handle,
+ w,
+ h,
+ md: li.md.clone(),
+ });
+ let md = li.md;
+ let have = gipfel_core::metadata::apply_image_metadata(&mut self.pan, &md, w, h);
+ self.known.clear();
+ self.track.clear();
+ self.sync_inputs();
+ self.fit = true;
+ self.remeasure();
+ self.zoom = self.fit_zoom();
+ self.panv = Vector::ZERO;
+ self.bump();
+ // prefill the profile name the auto-match used (C
+ // get_distortion_profile_name) and refresh the store list
+ let auto_profile = gipfel_core::metadata::distortion_profile_name(&md);
+ self.profile_in = auto_profile.clone().unwrap_or_default();
+ self.profile_overwrite = false;
+ self.refresh_profiles();
+ self.status = if have {
+ format!("loaded (gipfel info found)")
+ } else {
+ "loaded (no gipfel info: set viewpoint)".into()
+ };
+ if md.k0.is_nan() {
+ if let Some(name) =
+ auto_profile.filter(|n| self.profiles.iter().any(|p| p == n))
+ {
+ self.status = format!("{} - distortion profile '{name}'", self.status);
+ }
+ }
+ // the first frame after the image appears can be drawn
+ // before the texture is fully uploaded; a late nudge
+ // guarantees the photo shows without user interaction
+ return Task::perform(
+ async { tokio::time::sleep(std::time::Duration::from_millis(150)).await },
+ |_| cosmic::Action::App(Message::Redraw),
+ );
+ }
+ Err(e) => self.status = e,
+ },
+ Message::SaveImage => {
+ let Some(p) = &self.project else {
+ self.status = "no image loaded".into();
+ return Task::none();
+ };
+ let _ = p;
+ return cosmic::task::future(async move {
+ let filter = FileFilter::new("JPEG").glob("*.jpg");
+ Message::SavePath(pick_save("Save Image", filter).await)
+ });
+ }
+ Message::SavePath(out) => {
+ let (Some(out), Some(p)) = (out, &self.project) else { return Task::none() };
+ let md = self.md_from_state();
+ let src = p.path.clone();
+ return cosmic::task::future(async move {
+ match md.save_image(&src.to_string_lossy(), &out.to_string_lossy()) {
+ Ok(()) => Message::ImageSaved(Some(out)),
+ Err(e) => {
+ let _ = e;
+ Message::ImageSaved(None)
+ }
+ }
+ });
+ }
+ Message::ImageSaved(Some(p)) => {
+ self.status = format!("saved {}", p.display());
+ }
+ Message::ImageSaved(None) => {}
+ Message::DumpImage => {
+ let Some(p) = &self.project else {
+ self.status = "no image loaded".into();
+ return Task::none();
+ };
+ let path = p.path.clone();
+ // the dump is at image resolution; screen-pixel overlay sizes
+ // must be divided by the current zoom to look like the screen
+ let zoom = self.zoom.max(0.02) as f64;
+ let markers: Vec<dump::DumpMarker> = self
+ .markers()
+ .iter()
+ .map(|m| dump::DumpMarker {
+ x: m.x,
+ y: m.y,
+ name: m.name.to_string(),
+ known: m.known,
+ hidden: m.hidden,
+ })
+ .collect();
+ let track: Vec<dump::DumpTrackPt> = self
+ .track_pts()
+ .iter()
+ .map(|t| dump::DumpTrackPt { x: t.x, y: t.y, hidden: t.hidden, width: t.width })
+ .collect();
+ let font_size = self.label_font;
+ let font_family = cosmic::config::interface_font().family;
+ return cosmic::task::future(async move {
+ let res: Result<PathBuf, String> = async {
+ let filter = FileFilter::new("Images").glob("*.png").glob("*.jpg");
+ let out =
+ pick_save("Screen Dump", filter).await.ok_or_else(|| "cancelled".to_string())?;
+ let img = image::open(&path)
+ .map(|i| i.to_rgba8())
+ .map_err(|e| format!("{e}"))?;
+ dump::render(&img, &markers, &track, &out, font_size, 1.0 / zoom, &font_family)?;
+ Ok(out)
+ }
+ .await;
+ Message::ImageDumped(Box::new(res))
+ });
+ }
+ Message::ImageDumped(res) => {
+ self.status = match *res {
+ Ok(p) => format!("dumped {}", p.display()),
+ Err(e) => e,
+ };
+ }
+ Message::StitchTiff => {
+ let Some(p) = &self.project else {
+ self.status = "no image loaded".into();
+ return Task::none();
+ };
+ // cylindrical output spanning the lens' actual field of view
+ let edge = self.view_angle_limit(); // half-angle, radians
+ if !(edge > 0.0 && edge < 1.5) {
+ self.status = "cannot determine lens field of view".into();
+ return Task::none();
+ }
+ let center = self.pan.get_center_angle();
+ let (from, to) = (center - edge.to_degrees(), center + edge.to_degrees());
+ // output scale = the source's focal scale (pixels per radian
+ // at image center): the cylindrical image is 1:1 in the middle
+ // and slightly downsampled towards the edges (where the
+ // rectilinear source has increasing angular stretch). Using
+ // the source width instead would upsample ~5-10% and blur.
+ let radius = self.pan.get_scale();
+ let w_out = (2.0 * edge * radius).round_ties_even().max(16.0) as u32;
+ // cover the source's vertical extent (may be off-center
+ // with pitch); output rows are symmetric about elevation 0
+ let e_top = elev_at_img_y(&self.pan, -(p.h as f64 / 2.0));
+ let e_bot = elev_at_img_y(&self.pan, p.h as f64 / 2.0);
+ let e_max = e_top.abs().max(e_bot.abs()).min(85f64.to_radians());
+ let h_out = (2.0 * radius * e_max.tan()).round_ties_even().max(16.0) as u32;
+ let path = p.path.clone();
+ let pan = self.pan.params_snapshot();
+ return cosmic::task::future(async move {
+ let res: Result<PathBuf, String> = async {
+ let filter = FileFilter::new("TIFF images").glob("*.tif").glob("*.tiff");
+ let out = pick_save("Stitch Cylindrical TIFF", filter)
+ .await
+ .ok_or_else(|| "cancelled".to_string())?;
+ let out_for_task = out.clone();
+ tokio::task::spawn_blocking(move || -> Result<(), String> {
+ let mut src = stitch::StitchSource::load(&path.to_string_lossy())?;
+ // use the live (possibly adjusted) parameters
+ src.pan = pan;
+ let img = stitch::resample(
+ &[src],
+ stitch::ScanMode::Bicubic,
+ w_out,
+ h_out,
+ from,
+ to,
+ );
+ stitch::write_tiff(&out_for_task.to_string_lossy(), &img, true)
+ })
+ .await
+ .map_err(|e| e.to_string())??;
+ Ok(out)
+ }
+ .await;
+ Message::StitchDone(Box::new(res))
+ });
+ }
+ Message::StitchDone(res) => {
+ self.status = match *res {
+ Ok(p) => format!("stitched {}", p.display()),
+ Err(e) => e,
+ };
+ }
+ Message::LoadTrack => {
+ return cosmic::task::future(async move {
+ let res: Result<Vec<Hill>, String> = async {
+ let filter = FileFilter::new("Track files")
+ .glob("*.gpx")
+ .glob("*.csv")
+ .glob("*.dat");
+ let path = pick_open("Load GPX Track", filter)
+ .await
+ .ok_or_else(|| "cancelled".to_string())?;
+ let hs = load_track_file(&path).map_err(|e| e.to_string())?;
+ Ok(hs)
+ }
+ .await;
+ Message::TrackLoaded(Box::new(res))
+ });
+ }
+ Message::TrackLoaded(res) => match *res {
+ Ok(hills) => {
+ // remap known hills by name across the index shift caused by
+ // removing the old track
+ let names: Vec<String> = self.known.iter().map(|&i| self.pan.hills[i].name.clone()).collect();
+ self.pan.remove_hills(FLAG_TRACK_POINT);
+ let n = hills.len();
+ self.pan.add_hills(&hills);
+ let base = self.pan.hills.len() - n;
+ self.track = (base..base + n).collect();
+ self.known = names
+ .iter()
+ .filter_map(|n| self.pan.get_pos(n))
+ .collect();
+ self.status = format!("track: {n} points");
+ self.bump();
+ }
+ Err(e) => self.status = e,
+ },
+ Message::ChooseViewpoint => {
+ self.picker = Some(Picker {
+ title: "Choose Viewpoint".into(),
+ query: String::new(),
+ mode: PickerMode::Viewpoint,
+ });
+ }
+ Message::ContextFindPeak(x, y) => {
+ self.context = None;
+ self.picker = Some(Picker {
+ title: "Find Peak".into(),
+ query: String::new(),
+ mode: PickerMode::FindPeak { img_x: x, img_y: y },
+ });
+ }
+ Message::PickerQuery(q) => {
+ if let Some(p) = &mut self.picker {
+ p.query = q;
+ }
+ }
+ Message::PickerPick(i) => {
+ if let Some(p) = self.picker.take() {
+ match p.mode {
+ PickerMode::Viewpoint => {
+ self.pan.set_viewpoint(Some(i));
+ self.sync_inputs();
+ self.bump();
+ self.status = format!("viewpoint: {}", self.pan.hills[i].name);
+ }
+ PickerMode::FindPeak { img_x, img_y } => {
+ if !self.known.contains(&i) {
+ self.known.push(i);
+ }
+ self.pan.hills[i].flags |= FLAG_VISIBLE;
+ if !self.pan.visible.contains(&i) {
+ self.pan.visible.push(i);
+ }
+ self.pan.hills[i].x = img_x;
+ self.pan.hills[i].y = img_y;
+ self.refit();
+ }
+ }
+ }
+ }
+ Message::PickerClose => {
+ self.picker = None;
+ self.about = false;
+ self.context = None;
+ }
+ Message::ContextToggleHidden(i) => {
+ self.context = None;
+ self.pan.hills[i].flags ^= FLAG_HIDDEN;
+ self.bump();
+ }
+ Message::Surface(action) => {
+ return cosmic::task::message(cosmic::Action::Surface(action));
+ }
+ Message::Redraw => {}
+ Message::Center(a) => {
+ self.pan.set_center_angle(a);
+ self.bump();
+ }
+ Message::CompassStyle(s) => {
+ self.compass_style = s;
+ self.remeasure();
+ self.bump();
+ }
+ Message::Pitch(a) => {
+ self.pan.set_pitch_angle(a);
+ self.bump();
+ }
+ Message::Roll(a) => {
+ self.pan.set_roll_angle(a);
+ self.bump();
+ }
+ Message::LabelFont(s) => {
+ self.label_font = s;
+ self.bump();
+ }
+ Message::Focal(f) => {
+ self.set_focal_35mm(f);
+ self.bump();
+ }
+ Message::Visibility(v) => {
+ // the slider shows more visibility to the right; the core
+ // stores a height/distance threshold (higher = fewer hills)
+ self.pan.set_height_dist_ratio(0.11 - v);
+ self.bump();
+ }
+ Message::TrackWidth(w) => self.track_width = w,
+ Message::Lat(s) => self.lat_in = s,
+ Message::Lon(s) => self.lon_in = s,
+ Message::Height(s) => self.height_in = s,
+ Message::ViewApply => {
+ if let Ok(v) = self.lat_in.parse() {
+ self.pan.set_view_lat(v);
+ }
+ if let Ok(v) = self.lon_in.parse() {
+ self.pan.set_view_long(v);
+ }
+ if let Ok(v) = self.height_in.parse() {
+ self.pan.set_view_height(v);
+ }
+ self.bump();
+ }
+ Message::K0(s) => self.k0_in = s,
+ Message::K1(s) => self.k1_in = s,
+ Message::X0(s) => self.x0_in = s,
+ Message::DistortionApply => {
+ let k0 = self.k0_in.parse().unwrap_or(0.0);
+ let k1 = self.k1_in.parse().unwrap_or(0.0);
+ let x0 = self.x0_in.parse().unwrap_or(0.0);
+ self.pan.set_distortion_params(k0, k1, x0);
+ self.bump();
+ }
+ Message::ProfileName(s) => {
+ self.profile_in = s;
+ self.profile_overwrite = false;
+ }
+ Message::ProfileSave => {
+ let name = self.profile_in.trim().to_string();
+ if name.is_empty() {
+ self.status = "enter a profile name".into();
+ } else {
+ let p = gipfel_core::profile::Profile {
+ k0: self.pan.parms.k0,
+ k1: self.pan.parms.k1,
+ x0: self.pan.parms.x0,
+ };
+ let exists = self.profiles.iter().any(|n| *n == name);
+ if exists && !self.profile_overwrite {
+ self.profile_overwrite = true;
+ self.status =
+ format!("profile '{name}' exists - press Save again to overwrite");
+ } else {
+ match gipfel_core::profile::save(&name, p, true) {
+ Ok(()) => {
+ self.status = format!("saved profile '{name}'");
+ self.profile_overwrite = false;
+ self.refresh_profiles();
+ }
+ Err(e) => self.status = format!("save failed: {e}"),
+ }
+ }
+ }
+ }
+ Message::ProfileLoad => {
+ let name = self.profile_in.trim().to_string();
+ if !name.is_empty() {
+ self.apply_profile(&name);
+ }
+ }
+ Message::ProfileSelect(i) => {
+ if let Some(name) = self.profiles.get(i).cloned() {
+ self.profile_in = name.clone();
+ self.apply_profile(&name);
+ }
+ }
+ Message::Projection(t) => {
+ self.pan.set_projection(t);
+ self.bump();
+ }
+ Message::ToggleShowHidden => {
+ self.show_hidden = !self.show_hidden;
+ self.bump();
+ }
+ Message::FitDistortion(v) => {
+ self.fit_distortion = v;
+ // fitting a newly unlocked calibration takes effect at once
+ if v && self.known.len() >= 4 {
+ self.refit();
+ }
+ }
+ Message::Fit => {
+ self.fit = true;
+ self.zoom = self.fit_zoom();
+ self.panv = Vector::ZERO;
+ }
+ Message::ZoomIn => {
+ return self.update_vp(VpMsg::ZoomAt { factor: 1.25, img: cosmic::iced::Point::ORIGIN });
+ }
+ Message::ZoomOut => {
+ return self.update_vp(VpMsg::ZoomAt { factor: 0.8, img: cosmic::iced::Point::ORIGIN });
+ }
+ Message::About(b) => self.about = b,
+ Message::Quit => {
+ if let Some(id) = self.core.main_window_id() {
+ return cosmic::iced::window::close(id);
+ }
+ }
+ }
+ Task::none()
+ }
+
+ /// The settings panel lives in the framework-managed left nav slot
+ /// (standard padding, condensed-mode handling, collapse state).
+ fn nav_bar(&self) -> Option<Element<'_, cosmic::Action<Message>>> {
+ if !self.core().nav_bar_active() {
+ return None;
+ }
+ let close: Element<Message> = widget::button::icon(
+ widget::icon::from_name("pan-start-symbolic").size(16),
+ )
+ .on_press(Message::ToggleNav)
+ .into();
+ let panel: Element<Message> = widget::container(
+ widget::column::with_children([
+ widget::row::with_children([
+ widget::text::heading("gipfel").into(),
+ widget::container(close)
+ .width(Length::Fill)
+ .align_x(cosmic::iced::alignment::Horizontal::Right)
+ .into(),
+ ])
+ .align_y(cosmic::iced::alignment::Vertical::Center)
+ .spacing(8)
+ .into(),
+ widget::scrollable(self.sidebar()).into(),
+ ])
+ .spacing(10),
+ )
+ .padding([10, 12])
+ .width(Length::Fixed(340.0))
+ .height(Length::Fill)
+ .class(cosmic::theme::Container::Secondary)
+ .into();
+ Some(panel.map(cosmic::Action::App))
+ }
+
+ fn view(&self) -> Element<'_, Message> {
+ let content: Element<Message> = match &self.project {
+ Some(p) => {
+ let vp = ViewportWidget::<Message> {
+ handle: p.handle.clone(),
+ img_w: p.w as f32,
+ img_h: p.h as f32,
+ zoom: self.zoom,
+ pan: self.panv,
+ markers: self.markers(),
+ track: self.track_pts(),
+ hover: self.hover,
+ ctrl: self.ctrl,
+ font_size: self.label_font,
+ version: self.version,
+ _msg: PhantomData,
+ };
+ let vp: Element<Message> = Element::new(vp);
+ // standard popover context menu (like cosmic-files): opens in
+ // its own surface, never shifts the main content
+ let vp: Element<Message> =
+ cosmic::widget::context_menu(vp, self.context_menu_tree())
+ .window_id(
+ self.core
+ .main_window_id()
+ .unwrap_or(cosmic::iced::window::Id::RESERVED),
+ )
+ .on_surface_action(Message::Surface)
+ .into();
+ if self.compass_style == CompassStyle::Off {
+ vp
+ } else {
+ let fov = match self.pan.projection_type {
+ ProjectionType::Rectilinear => (
+ 2.0 * ((p.w as f64 * 0.5) / self.pan.parms.scale).atan()
+ )
+ .to_degrees(),
+ ProjectionType::Cylindrical => {
+ (p.w as f64 / self.pan.parms.scale).to_degrees()
+ }
+ };
+ let strip: Element<Message> = Element::new(Compass {
+ style: self.compass_style,
+ center_deg: self.pan.get_center_angle(),
+ zoom: self.zoom,
+ pan_x: self.panv.x,
+ parms: self.pan.parms,
+ ptype: self.pan.projection_type,
+ fov_deg: fov,
+ max_view_angle: self.pan.max_view_angle,
+ on_change: Rc::new(Message::Center),
+ });
+ widget::column::with_children([vp, strip]).into()
+ }
+ }
+ None => widget::container(
+ widget::column::with_children([
+ widget::text::title2("gipfel").into(),
+ widget::text("Open a JPEG to start (File ▸ Open Image…, Ctrl+O).").into(),
+ widget::button::suggested("Open Image…").on_press(Message::OpenImage).into(),
+ ])
+ .spacing(12)
+ .align_x(cosmic::iced::alignment::Horizontal::Center),
+ )
+ .into(),
+ };
+
+ let main: Element<Message> = content;
+
+ let status_text = self.hover_info.as_deref().unwrap_or(&self.status);
+ let status: Element<Message> = widget::container(widget::text::body(status_text.to_string()))
+ .padding([4, 10])
+ .width(Length::Fill)
+ .style(|_: &Theme| widget::container::Style {
+ background: Some(cosmic::iced::Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.15))),
+ ..Default::default()
+ })
+ .into();
+
+ let body: Element<Message> = widget::column::with_children([main, status]).into();
+
+ if let Some(picker) = &self.picker {
+ return self.with_dialog(body, self.view_picker(picker));
+ }
+ if self.about {
+ return self.with_dialog(body, self.view_about());
+ }
+ body
+ }
+}
+
+// helpers split out to keep view() readable
+
+/// settings row with the label above a full-width, easy-grip slider
+fn slider_item(
+ label: &str,
+ value: String,
+ slider: impl Into<Element<'static, Message>>,
+) -> Element<'static, Message> {
+ let header: Element<'static, Message> = widget::row::with_children([
+ widget::text::body(label.to_string()).into(),
+ widget::container(widget::text::body(value))
+ .width(Length::Fill)
+ .align_x(cosmic::iced::alignment::Horizontal::Right)
+ .into(),
+ ])
+ .into();
+ let body: Element<'static, Message> = slider.into();
+ widget::column::with_children([header, body])
+ .spacing(2)
+ .into()
+}
+
+/// Async file chooser: returns the chosen path, or None if cancelled.
+async fn pick_open(
+ title: &str,
+ filter: cosmic::dialog::file_chooser::FileFilter,
+) -> Option<PathBuf> {
+ cosmic::dialog::file_chooser::open::Dialog::new()
+ .title(title.to_string())
+ .filter(filter)
+ .open_file()
+ .await
+ .ok()?
+ .url()
+ .to_file_path()
+ .ok()
+}
+
+/// Async save dialog: returns the target path, or None if cancelled.
+async fn pick_save(
+ title: &str,
+ filter: cosmic::dialog::file_chooser::FileFilter,
+) -> Option<PathBuf> {
+ cosmic::dialog::file_chooser::save::Dialog::new()
+ .title(title.to_string())
+ .filter(filter)
+ .save_file()
+ .await
+ .ok()?
+ .url()
+ .and_then(|u| u.to_file_path().ok())
+}
+
+impl Gipfel {
+ fn with_dialog<'a>(&self, body: Element<'a, Message>, dialog: Element<'a, Message>) -> Element<'a, Message> {
+ widget::column::with_children([
+ widget::container(dialog).width(Length::Fill).into(),
+ body,
+ ])
+ .into()
+ }
+
+ fn view_picker<'a>(&self, p: &'a Picker) -> Element<'a, Message> {
+ let entries = self.picker_entries(p);
+ let mut list = widget::column::with_capacity(entries.len());
+ let mut empty = true;
+ for (label, idx) in entries {
+ empty = false;
+ list = list.push(
+ widget::button::text(label)
+ .on_press(Message::PickerPick(idx))
+ .width(Length::Fill),
+ );
+ }
+ if empty {
+ list = list.push(widget::text("no matches"));
+ }
+
+ widget::dialog()
+ .title(p.title.clone())
+ .body("Type to search, click to select.")
+ .control(
+ widget::text_input("search…", &p.query)
+ .on_input(Message::PickerQuery),
+ )
+ .control({
+ let el: Element<Message> = list.into();
+ widget::scrollable(el).height(Length::Fixed(320.0))
+ })
+ .secondary_action(widget::button::text("Cancel").on_press(Message::PickerClose))
+ .is_overlay(false)
+ .into()
+ }
+
+ fn context_menu_tree(&self) -> Option<Vec<menu::Tree<Message>>> {
+ let ctx = self.context.as_ref()?;
+ let mut items: Vec<menu::Item<CtxAction, String>> = Vec::new();
+ // placing a marker only makes sense on empty image space, not on an
+ // existing peak marker
+ if ctx.hit.is_none() {
+ items.push(menu::Item::Button(
+ "Find Peak…".to_string(),
+ None,
+ CtxAction::FindPeak(ctx.img_x, ctx.img_y),
+ ));
+ }
+ if let Some(i) = ctx.hit {
+ let h = &self.pan.hills[i];
+ items.push(menu::Item::Button(
+ format!(
+ "{} {} ({} m)",
+ if h.flags & FLAG_HIDDEN != 0 { "Unhide" } else { "Hide" },
+ h.name,
+ h.height as i64
+ ),
+ None,
+ CtxAction::ToggleHidden(i),
+ ));
+ }
+ if items.is_empty() {
+ return None;
+ }
+ Some(menu::items(&std::collections::HashMap::new(), items))
+ }
+
+ fn view_about<'a>(&self) -> Element<'a, Message> {
+ widget::dialog()
+ .title("About gipfel")
+ .body("gipfel 0.5.0 (COSMIC preview)")
+ .control(widget::text("Annotate mountain panoramas."))
+ .control(widget::text("Port of gipfel by Johannes Hofmann - mathematical models unchanged.")) .primary_action(widget::button::suggested("Close").on_press(Message::About(false)))
+ .is_overlay(false)
+ .into()
+ }
+
+ fn sidebar(&self) -> Element<'_, Message> {
+ let has_img = self.project.is_some();
+ let focal = self.focal_35mm().clamp(1.0, 300.0);
+ let pitch = self.pan.get_pitch_angle().clamp(-40.0, 20.0);
+ let roll = self.pan.get_roll_angle().clamp(-10.0, 10.0);
+ let vis = self.pan.height_dist_ratio.clamp(0.01, 0.1);
+ let vis_ui = 0.11 - vis; // slider: right = more hills visible
+ let label_font = self.label_font;
+ let center = self.pan.get_center_angle().rem_euclid(360.0);
+
+ let vp_name = self
+ .pan
+ .view_name
+ .clone()
+ .unwrap_or_else(|| "manual".to_string());
+
+ let sec_viewpoint = widget::settings::section()
+ .title("Viewpoint")
+ .add(widget::settings::item(
+ "Position",
+ widget::row::with_children([
+ widget::text(vp_name).into(),
+ widget::button::text("Choose…").on_press(Message::ChooseViewpoint).into(),
+ ])
+ .spacing(8),
+ ))
+ .add(widget::settings::item(
+ "Latitude",
+ widget::text_input("°", &self.lat_in)
+ .on_input(Message::Lat)
+ .on_submit(|_| Message::ViewApply),
+ ))
+ .add(widget::settings::item(
+ "Longitude",
+ widget::text_input("°", &self.lon_in)
+ .on_input(Message::Lon)
+ .on_submit(|_| Message::ViewApply),
+ ))
+ .add(widget::settings::item(
+ "Height (m)",
+ widget::text_input("m", &self.height_in)
+ .on_input(Message::Height)
+ .on_submit(|_| Message::ViewApply),
+ ));
+
+ let sec_orientation = widget::settings::section()
+ .title("Orientation")
+ .add(widget::settings::item(
+ "Center (deg)",
+ widget::text(format!("{center:.1}°")),
+ ))
+ .add(slider_item(
+ "Focal length (35mm)",
+ format!("{focal:.1} mm"),
+ widget::slider(1.0..=300.0, focal, Message::Focal)
+ .step(0.01)
+ .height(30.0),
+ ))
+ .add(slider_item(
+ "Pitch (deg)",
+ format!("{pitch:.1}°"),
+ widget::slider(-40.0..=20.0, pitch, Message::Pitch)
+ .step(0.01)
+ .height(30.0),
+ ))
+ .add(slider_item(
+ "Roll (deg)",
+ format!("{roll:.1}°"),
+ widget::slider(-10.0..=10.0, roll, Message::Roll)
+ .step(0.01)
+ .height(30.0),
+ ));
+
+ let sec_display = widget::settings::section()
+ .title("Display")
+ .add(slider_item(
+ "Visibility",
+ format!("{:.0}%", (vis_ui - 0.01) / 0.09 * 100.0),
+ widget::slider(0.01..=0.1, vis_ui, Message::Visibility)
+ .step(0.001)
+ .height(30.0),
+ ))
+ .add(slider_item(
+ "Track width",
+ format!("{:.0} m at 1 km", self.track_width),
+ widget::slider(1.0..=500.0, self.track_width, Message::TrackWidth)
+ .step(1.0)
+ .height(30.0),
+ ))
+ .add(slider_item(
+ "Label size",
+ format!("{label_font:.0} px"),
+ widget::slider(8.0f32..=28.0, label_font, Message::LabelFont)
+ .step(1.0)
+ .height(30.0),
+ ))
+ .add(widget::settings::item(
+ "Show hidden",
+ widget::toggler(self.show_hidden).on_toggle(|_| Message::ToggleShowHidden),
+ ))
+ .add(widget::row::with_children([
+ widget::button::text("Zoom −").on_press(Message::ZoomOut).into(),
+ widget::button::text("Fit").on_press(Message::Fit).into(),
+ widget::button::text("Zoom +").on_press(Message::ZoomIn).into(),
+ widget::text(format!("{:.0}%", self.zoom * 100.0)).into(),
+ ])
+ .spacing(8));
+
+ let sec_projection = widget::settings::section().title("Projection").add(
+ widget::row::with_children([
+ widget::radio(
+ "Normal",
+ ProjectionType::Rectilinear,
+ Some(self.pan.projection_type),
+ Message::Projection,
+ )
+ .into(),
+ widget::radio(
+ "Panoramic",
+ ProjectionType::Cylindrical,
+ Some(self.pan.projection_type),
+ Message::Projection,
+ )
+ .into(),
+ ])
+ .spacing(16),
+ );
+
+ // distortion fitting is locked unless enough markers are placed
+ let can_fit = self.known.len() >= 4;
+ let fit_cb = widget::checkbox(self.fit_distortion);
+ let fit_cb = if can_fit {
+ fit_cb.on_toggle(Message::FitDistortion)
+ } else {
+ fit_cb.on_toggle_maybe(None as Option<fn(bool) -> Message>)
+ };
+ let mut fit_col = vec![fit_cb.into()];
+ if !can_fit {
+ fit_col.push(widget::text("position 4 or more markers to enable").size(11).into());
+ }
+
+ // fixed-width label column: every control starts at the same x and
+ // therefore shares the same width (settings::item sizes labels to
+ // their text, which made the profile field narrower than k0/k1/x0)
+ let mut sec_distortion = widget::settings::section()
+ .title("Distortion")
+ .add(widget::settings::item_row(vec![
+ widget::text("Fit distortion").width(110).into(),
+ widget::column::with_children(fit_col).spacing(2).into(),
+ ]))
+ .add(widget::settings::item_row(vec![
+ widget::text("k0").width(110).into(),
+ widget::text_input("0", &self.k0_in)
+ .on_input(Message::K0)
+ .on_submit(|_| Message::DistortionApply)
+ .into(),
+ ]))
+ .add(widget::settings::item_row(vec![
+ widget::text("k1").width(110).into(),
+ widget::text_input("0", &self.k1_in)
+ .on_input(Message::K1)
+ .on_submit(|_| Message::DistortionApply)
+ .into(),
+ ]))
+ .add(widget::settings::item_row(vec![
+ widget::text("x0").width(110).into(),
+ widget::text_input("0", &self.x0_in)
+ .on_input(Message::X0)
+ .on_submit(|_| Message::DistortionApply)
+ .into(),
+ ]))
+ .add(widget::settings::item_row(vec![
+ widget::text("profile").width(110).into(),
+ widget::text_input("Camera_Model_50.00_mm", &self.profile_in)
+ .on_input(Message::ProfileName)
+ .on_submit(|_| Message::ProfileSave)
+ .into(),
+ ]))
+ .add(
+ widget::row::with_children([
+ widget::button::suggested("Save profile")
+ .on_press(Message::ProfileSave)
+ .into(),
+ widget::button::text("Load profile")
+ .on_press(Message::ProfileLoad)
+ .into(),
+ ])
+ .spacing(8),
+ );
+ if !self.profiles.is_empty() {
+ // stored profiles (shared with the C version); selecting one
+ // loads its parameters
+ let win = self
+ .core
+ .main_window_id()
+ .unwrap_or(cosmic::iced::window::Id::RESERVED);
+ sec_distortion = sec_distortion.add(widget::settings::item_row(vec![
+ widget::text("stored").width(110).into(),
+ widget::dropdown::popup_dropdown(
+ self.profiles.clone(),
+ None,
+ Message::ProfileSelect,
+ win,
+ Message::Surface,
+ |m| m,
+ )
+ .into(),
+ ]));
+ }
+
+ let mut col = widget::column::with_children([
+ sec_viewpoint.into(),
+ sec_orientation.into(),
+ sec_projection.into(),
+ sec_distortion.into(),
+ sec_display.into(),
+ ])
+ .spacing(12)
+ .padding(12);
+
+ if !has_img {
+ col = col.push(widget::text("Load an image to enable the controls."));
+ }
+ col.into()
+ }
+}
+
+fn main() -> cosmic::iced::Result {
+ let mut flags = Flags { data: None, image: None };
+ let mut args = std::env::args().skip(1);
+ while let Some(a) = args.next() {
+ match a.as_str() {
+ "--data" => flags.data = args.next().map(PathBuf::from),
+ s if !s.starts_with('-') && flags.image.is_none() => flags.image = Some(PathBuf::from(s)),
+ _ => {}
+ }
+ }
+ cosmic::app::run::<Gipfel>(
+ cosmic::app::Settings::default().size(cosmic::iced::Size::new(1280.0, 800.0)),
+ flags,
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn elev_inverse_matches_projection() {
+ let mut pan = Panorama::new();
+ pan.set_projection(ProjectionType::Rectilinear);
+ pan.set_center_angle(0.0);
+ pan.set_pitch_angle(0.0);
+ pan.set_scale(200.0);
+ // undistorted rectilinear: y = scale * tan(pitch - elev)
+ for &y in &[-100.0, -30.0, 0.0, 45.0, 120.0] {
+ let e = elev_at_img_y(&pan, y);
+ let (_, y_back) = pan.project_point(0.0, e).unwrap();
+ assert!((y_back - y).abs() < 0.01, "y={y} -> elev={e} -> {y_back}");
+ }
+ // with pitch the band is asymmetric but still invertible
+ pan.set_pitch_angle(10.0);
+ let p = pan.parms.a_pitch;
+ let top = elev_at_img_y(&pan, -100.0);
+ let bot = elev_at_img_y(&pan, 100.0);
+ assert!(top > p && bot < p, "top={top} p={p} bot={bot}");
+ let (_, y_back) = pan.project_point(0.0, top).unwrap();
+ assert!((y_back + 100.0).abs() < 0.01);
+ }
+}