more ui tweaks
This commit is contained in:
+132
-129
@@ -2,29 +2,32 @@
|
||||
//!
|
||||
//! `AppModel` is an `AsyncComponent` that drives the full lifecycle:
|
||||
//! 1. First-run setup — collect birth date + location, compute chart
|
||||
//! 2. Main view — display natal aspects, current transits, and
|
||||
//! peer discovery with approximate synastry glyphs
|
||||
//! 3. Network events — `CommandOutput = ZodiaNetEvent` keeps the peer
|
||||
//! 2. Main view — Chart / Sky / Peers tabs in an `adw::NavigationView`
|
||||
//! 3. Connected peer — pushed `adw::NavigationPage` with synastry + call
|
||||
//! 4. Network events — `CommandOutput = ZodiaNetEvent` keeps the peer
|
||||
//! list reactive without blocking the GTK thread
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
|
||||
use libadwaita as adw;
|
||||
use libadwaita::prelude::*; // also re-exports gtk::prelude
|
||||
use libadwaita::prelude::*;
|
||||
use relm4::factory::FactoryVecDeque;
|
||||
use relm4::prelude::*;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use tracing::{error, info};
|
||||
use tracing::{error, info, warn};
|
||||
use zodia_av::AudioSession;
|
||||
use zodia_config::LocalConfig;
|
||||
use zodia_core::{birth_from_coords, current_jdn, gregorian_to_jdn, Chart};
|
||||
use zodia_net::{ChannelMsg, DirectChannel, NetworkConfig, PeerId, ZodiaNetEvent, ZodiaNetwork};
|
||||
use zodia_net::{ChannelMsg, DirectChannel, NetworkConfig, PeerId, Tier1Blob, ZodiaNetEvent,
|
||||
ZodiaNetwork};
|
||||
use zodia_store::ZodiaStore;
|
||||
|
||||
use crate::aspect_list;
|
||||
use crate::aspect_view::AspectView;
|
||||
use crate::peer_list::{PeerEntry, PeerInit, PeerOutput};
|
||||
use crate::peer_page;
|
||||
use crate::util::approximate_aspects;
|
||||
|
||||
// ── init ──────────────────────────────────────────────────────────────────────
|
||||
@@ -84,7 +87,7 @@ pub struct AppModel {
|
||||
on_setup_page: bool,
|
||||
chart: Option<Chart>,
|
||||
|
||||
// Shared store — wrapped so signal handlers in aspect rows can access it.
|
||||
/// Shared store — Rc so GTK signal closures can borrow it.
|
||||
store: Rc<RefCell<ZodiaStore>>,
|
||||
|
||||
network: Option<ZodiaNetwork>,
|
||||
@@ -99,6 +102,10 @@ pub struct AppModel {
|
||||
|
||||
call_state: CallState,
|
||||
connected_channels: HashMap<PeerId, DirectChannel>,
|
||||
|
||||
/// Peers whose Tier-1 exchange has completed — drives peer page creation.
|
||||
connected_peers: HashMap<PeerId, Tier1Blob>,
|
||||
|
||||
active_audio: Option<AudioSession>,
|
||||
}
|
||||
|
||||
@@ -109,9 +116,10 @@ pub struct AppWidgets {
|
||||
outer_stack: gtk::Stack,
|
||||
setup_status: gtk::Label,
|
||||
|
||||
// ListBox widgets for the chart / sky tabs (lazily populated).
|
||||
natal_list: gtk::ListBox,
|
||||
transit_list: gtk::ListBox,
|
||||
/// Container for the natal `AspectView` — lazily populated.
|
||||
chart_container: gtk::Box,
|
||||
/// Container for the transit `AspectView` — lazily populated.
|
||||
sky_container: gtk::Box,
|
||||
|
||||
peers_page: adw::ViewStackPage,
|
||||
peer_count_label: gtk::Label,
|
||||
@@ -122,8 +130,10 @@ pub struct AppWidgets {
|
||||
accept_btn: gtk::Button,
|
||||
hangup_btn: gtk::Button,
|
||||
|
||||
// Root window reference — needed by update_view to pass to dialog builder.
|
||||
window: adw::ApplicationWindow,
|
||||
/// NavigationView holding the main page + pushed peer pages.
|
||||
nav_view: adw::NavigationView,
|
||||
/// Peers already pushed as navigation pages (avoids duplicates).
|
||||
shown_peers: HashSet<PeerId>,
|
||||
}
|
||||
|
||||
// ── async component ───────────────────────────────────────────────────────────
|
||||
@@ -169,6 +179,7 @@ impl AsyncComponent for AppModel {
|
||||
author_pk,
|
||||
call_state: CallState::Idle,
|
||||
connected_channels: HashMap::new(),
|
||||
connected_peers: HashMap::new(),
|
||||
active_audio: None,
|
||||
};
|
||||
|
||||
@@ -252,6 +263,21 @@ impl AsyncComponent for AppModel {
|
||||
match net.connect_peer(&peer_id).await {
|
||||
Ok(channel) => {
|
||||
info!(peer = %hex::encode_upper(&peer_id.0[..4]), "tier-1 channel opened");
|
||||
|
||||
// Exchange birth data before registering the channel listener
|
||||
// so the handshake stream is consumed by exchange_tier1, not
|
||||
// the generic message loop.
|
||||
if let Some(our_blob) = make_tier1_blob(&self.config) {
|
||||
match channel.exchange_tier1(&our_blob).await {
|
||||
Ok(their_blob) => {
|
||||
info!(peer = %hex::encode_upper(&peer_id.0[..4]),
|
||||
"tier-1 exchange complete");
|
||||
self.connected_peers.insert(peer_id.clone(), their_blob);
|
||||
}
|
||||
Err(e) => warn!("tier-1 exchange: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
net.accept_channel(peer_id.clone(), channel.clone());
|
||||
self.connected_channels.insert(peer_id, channel);
|
||||
}
|
||||
@@ -351,6 +377,23 @@ impl AsyncComponent for AppModel {
|
||||
self.peer_count = self.peer_count.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
ZodiaNetEvent::IncomingChannel { peer_id, channel } => {
|
||||
if let Some(net) = &self.network {
|
||||
// Exchange before registering the message loop.
|
||||
if let Some(our_blob) = make_tier1_blob(&self.config) {
|
||||
match channel.exchange_tier1(&our_blob).await {
|
||||
Ok(their_blob) => {
|
||||
info!(peer = %hex::encode_upper(&peer_id.0[..4]),
|
||||
"tier-1 exchange complete (incoming)");
|
||||
self.connected_peers.insert(peer_id.clone(), their_blob);
|
||||
}
|
||||
Err(e) => warn!("tier-1 exchange (incoming): {e}"),
|
||||
}
|
||||
}
|
||||
net.accept_channel(peer_id.clone(), channel.clone());
|
||||
self.connected_channels.insert(peer_id, channel);
|
||||
}
|
||||
}
|
||||
ZodiaNetEvent::CallOffer { from, session_id } => {
|
||||
self.call_state = CallState::Ringing { peer_id: from, session_id };
|
||||
}
|
||||
@@ -365,18 +408,11 @@ impl AsyncComponent for AppModel {
|
||||
self.active_audio = None;
|
||||
self.call_state = CallState::Idle;
|
||||
}
|
||||
ZodiaNetEvent::IncomingChannel { peer_id, channel } => {
|
||||
if let Some(net) = &self.network {
|
||||
info!(peer = %hex::encode_upper(&peer_id.0[..4]), "incoming tier-1 channel");
|
||||
net.accept_channel(peer_id.clone(), channel.clone());
|
||||
self.connected_channels.insert(peer_id, channel);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_view(&self, widgets: &mut Self::Widgets, _sender: AsyncComponentSender<Self>) {
|
||||
fn update_view(&self, widgets: &mut Self::Widgets, sender: AsyncComponentSender<Self>) {
|
||||
if self.on_setup_page {
|
||||
widgets.outer_stack.set_visible_child_name("setup");
|
||||
} else {
|
||||
@@ -384,51 +420,44 @@ impl AsyncComponent for AppModel {
|
||||
}
|
||||
widgets.setup_status.set_text(&self.setup_error);
|
||||
|
||||
// Lazily populate aspect lists the first time the main page is shown.
|
||||
// `first_child().is_none()` means the list has never been populated.
|
||||
if !self.on_setup_page && widgets.natal_list.first_child().is_none() {
|
||||
// Lazily populate aspect views the first time the main page is shown.
|
||||
if !self.on_setup_page && widgets.chart_container.first_child().is_none() {
|
||||
if let Some(chart) = &self.chart {
|
||||
// Natal aspects
|
||||
let nat = aspect_list::natal_items(&chart.natal_aspects());
|
||||
let nat_list = aspect_list::build_list(
|
||||
nat,
|
||||
let nav = AspectView::natal(
|
||||
aspect_list::natal_items(&chart.natal_aspects()),
|
||||
chart,
|
||||
Rc::clone(&self.store),
|
||||
self.author_pk,
|
||||
&widgets.window,
|
||||
);
|
||||
// Swap the placeholder list for the populated one.
|
||||
// We built natal_list as an empty ListBox; populate it in-place.
|
||||
for item in nat_list
|
||||
.observe_children()
|
||||
.into_iter()
|
||||
.flat_map(|o| o.ok())
|
||||
.flat_map(|o| o.downcast::<gtk::Widget>().ok())
|
||||
{
|
||||
nat_list.remove(&item);
|
||||
widgets.natal_list.append(&item);
|
||||
}
|
||||
nav.widget().set_vexpand(true);
|
||||
widgets.chart_container.append(nav.widget());
|
||||
|
||||
// Transit aspects
|
||||
if let Ok(ts) = chart.transits_at(current_jdn()) {
|
||||
let tr = aspect_list::transit_items(
|
||||
&ts.transit_aspects,
|
||||
&ts.house_transits,
|
||||
);
|
||||
let tr_list = aspect_list::build_list(
|
||||
tr,
|
||||
let tav = AspectView::new(
|
||||
aspect_list::transit_items(&ts.transit_aspects, &ts.house_transits),
|
||||
Rc::clone(&self.store),
|
||||
self.author_pk,
|
||||
&widgets.window,
|
||||
);
|
||||
for item in tr_list
|
||||
.observe_children()
|
||||
.into_iter()
|
||||
.flat_map(|o| o.ok())
|
||||
.flat_map(|o| o.downcast::<gtk::Widget>().ok())
|
||||
{
|
||||
tr_list.remove(&item);
|
||||
widgets.transit_list.append(&item);
|
||||
}
|
||||
tav.widget().set_vexpand(true);
|
||||
widgets.sky_container.append(tav.widget());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push a navigation page for each newly connected peer.
|
||||
for (peer_id, their_blob) in &self.connected_peers {
|
||||
if !widgets.shown_peers.contains(peer_id) {
|
||||
if let Some(chart) = &self.chart {
|
||||
let page = peer_page::build_peer_page(
|
||||
peer_id,
|
||||
their_blob,
|
||||
chart,
|
||||
Rc::clone(&self.store),
|
||||
self.author_pk,
|
||||
&sender,
|
||||
);
|
||||
widgets.nav_view.push(&page);
|
||||
widgets.shown_peers.insert(peer_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -512,6 +541,18 @@ fn start_network_command(
|
||||
});
|
||||
}
|
||||
|
||||
/// Construct our Tier-1 blob with exact birth data.
|
||||
///
|
||||
/// Prekey/ephemeral fields are zeroed — crypto key exchange is deferred
|
||||
/// until message support is added (see zodia-crypto).
|
||||
fn make_tier1_blob(config: &LocalConfig) -> Option<Tier1Blob> {
|
||||
config.birth.as_ref().map(|birth| Tier1Blob {
|
||||
birth: birth.clone(),
|
||||
prekey: [0u8; 32], // TODO: real X25519 prekey
|
||||
ephemeral: [0u8; 32], // TODO: real ephemeral key for X3DH
|
||||
})
|
||||
}
|
||||
|
||||
// ── widget construction ───────────────────────────────────────────────────────
|
||||
|
||||
fn build_widgets(
|
||||
@@ -528,34 +569,32 @@ fn build_widgets(
|
||||
let (setup_page, setup_status) = build_setup_page(sender);
|
||||
outer_stack.add_named(&setup_page, Some("setup"));
|
||||
|
||||
let (main_page, natal_list, transit_list, peers_page, peer_count_label,
|
||||
let (nav_view, chart_container, sky_container, peers_page, peer_count_label,
|
||||
node_id_label, peers_scrolled, call_bar, call_status, accept_btn, hangup_btn) =
|
||||
build_main_page(model, sender);
|
||||
outer_stack.add_named(&main_page, Some("main"));
|
||||
outer_stack.add_named(&nav_view, Some("main"));
|
||||
|
||||
peers_scrolled.set_child(Some(model.peers.widget()));
|
||||
|
||||
// If chart is already available (returning user), populate the lists now
|
||||
// so they appear immediately without waiting for a model mutation.
|
||||
// For returning users with an existing chart, populate aspect views now.
|
||||
if let Some(chart) = &model.chart {
|
||||
let nat = aspect_list::natal_items(&chart.natal_aspects());
|
||||
let nat_list = aspect_list::build_list(
|
||||
nat,
|
||||
let nav = AspectView::natal(
|
||||
aspect_list::natal_items(&chart.natal_aspects()),
|
||||
chart,
|
||||
Rc::clone(&model.store),
|
||||
model.author_pk,
|
||||
root,
|
||||
);
|
||||
reparent_children(&nat_list, &natal_list);
|
||||
nav.widget().set_vexpand(true);
|
||||
chart_container.append(nav.widget());
|
||||
|
||||
if let Ok(ts) = chart.transits_at(current_jdn()) {
|
||||
let tr = aspect_list::transit_items(&ts.transit_aspects, &ts.house_transits);
|
||||
let tr_list = aspect_list::build_list(
|
||||
tr,
|
||||
let tav = AspectView::new(
|
||||
aspect_list::transit_items(&ts.transit_aspects, &ts.house_transits),
|
||||
Rc::clone(&model.store),
|
||||
model.author_pk,
|
||||
root,
|
||||
);
|
||||
reparent_children(&tr_list, &transit_list);
|
||||
tav.widget().set_vexpand(true);
|
||||
sky_container.append(tav.widget());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -570,8 +609,8 @@ fn build_widgets(
|
||||
AppWidgets {
|
||||
outer_stack,
|
||||
setup_status,
|
||||
natal_list,
|
||||
transit_list,
|
||||
chart_container,
|
||||
sky_container,
|
||||
peers_page,
|
||||
peer_count_label,
|
||||
node_id_label,
|
||||
@@ -579,19 +618,8 @@ fn build_widgets(
|
||||
call_status,
|
||||
accept_btn,
|
||||
hangup_btn,
|
||||
window: root.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Move all children from `src` list into `dst` list.
|
||||
///
|
||||
/// `build_list` returns a freshly constructed `gtk::ListBox`. Since GTK
|
||||
/// widgets can only have one parent, we detach each row from `src` and
|
||||
/// append it to `dst` (the list that lives inside our widget hierarchy).
|
||||
fn reparent_children(src: >k::ListBox, dst: >k::ListBox) {
|
||||
while let Some(child) = src.first_child() {
|
||||
src.remove(&child);
|
||||
dst.append(&child);
|
||||
nav_view,
|
||||
shown_peers: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -635,7 +663,6 @@ fn build_setup_page(
|
||||
subtitle.set_max_width_chars(50);
|
||||
content.append(&subtitle);
|
||||
|
||||
// Birth date / time group
|
||||
let date_group = adw::PreferencesGroup::new();
|
||||
date_group.set_title("Birth Date & Time");
|
||||
|
||||
@@ -666,7 +693,6 @@ fn build_setup_page(
|
||||
|
||||
content.append(&date_group);
|
||||
|
||||
// Location group
|
||||
let loc_group = adw::PreferencesGroup::new();
|
||||
loc_group.set_title("Birth Location");
|
||||
|
||||
@@ -724,60 +750,35 @@ fn build_setup_page(
|
||||
|
||||
// ── main page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[allow(deprecated)] // ViewSwitcherTitle — see note in aspect_list.rs
|
||||
#[allow(deprecated)] // ViewSwitcherTitle deprecated in ADW 1.4; migrate when bindings catch up
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn build_main_page(
|
||||
model: &AppModel,
|
||||
sender: &AsyncComponentSender<AppModel>,
|
||||
) -> (
|
||||
adw::ToolbarView,
|
||||
gtk::ListBox, gtk::ListBox,
|
||||
adw::ViewStackPage, gtk::Label,
|
||||
gtk::Label,
|
||||
gtk::ScrolledWindow,
|
||||
gtk::Box, gtk::Label, gtk::Button, gtk::Button,
|
||||
adw::NavigationView,
|
||||
gtk::Box, gtk::Box, // chart_container, sky_container
|
||||
adw::ViewStackPage, gtk::Label, // peers_page, peer_count_label
|
||||
gtk::Label, // node_id_label
|
||||
gtk::ScrolledWindow, // peers_scrolled
|
||||
gtk::Box, gtk::Label, gtk::Button, gtk::Button, // call bar widgets
|
||||
) {
|
||||
let toolbar_view = adw::ToolbarView::new();
|
||||
let view_stack = adw::ViewStack::new();
|
||||
|
||||
// ── Chart tab ─────────────────────────────────────────────────────────────
|
||||
let chart_scroll = gtk::ScrolledWindow::new();
|
||||
chart_scroll.set_hscrollbar_policy(gtk::PolicyType::Never);
|
||||
let chart_clamp = adw::Clamp::new();
|
||||
chart_clamp.set_maximum_size(720);
|
||||
chart_clamp.set_margin_top(16);
|
||||
chart_clamp.set_margin_bottom(16);
|
||||
chart_clamp.set_margin_start(12);
|
||||
chart_clamp.set_margin_end(12);
|
||||
let chart_container = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
chart_container.set_vexpand(true);
|
||||
|
||||
// Placeholder list — populated lazily (or immediately if chart is known).
|
||||
let natal_list = gtk::ListBox::new();
|
||||
natal_list.add_css_class("boxed-list");
|
||||
natal_list.set_selection_mode(gtk::SelectionMode::None);
|
||||
chart_clamp.set_child(Some(&natal_list));
|
||||
chart_scroll.set_child(Some(&chart_clamp));
|
||||
|
||||
let chart_page = view_stack.add_titled(&chart_scroll, Some("chart"), "Chart");
|
||||
let chart_page = view_stack.add_titled(&chart_container, Some("chart"), "Chart");
|
||||
chart_page.set_icon_name(Some("weather-clear-symbolic"));
|
||||
let _ = chart_page;
|
||||
|
||||
// ── Sky tab ───────────────────────────────────────────────────────────────
|
||||
let sky_scroll = gtk::ScrolledWindow::new();
|
||||
sky_scroll.set_hscrollbar_policy(gtk::PolicyType::Never);
|
||||
let sky_clamp = adw::Clamp::new();
|
||||
sky_clamp.set_maximum_size(720);
|
||||
sky_clamp.set_margin_top(16);
|
||||
sky_clamp.set_margin_bottom(16);
|
||||
sky_clamp.set_margin_start(12);
|
||||
sky_clamp.set_margin_end(12);
|
||||
let sky_container = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
sky_container.set_vexpand(true);
|
||||
|
||||
let transit_list = gtk::ListBox::new();
|
||||
transit_list.add_css_class("boxed-list");
|
||||
transit_list.set_selection_mode(gtk::SelectionMode::None);
|
||||
sky_clamp.set_child(Some(&transit_list));
|
||||
sky_scroll.set_child(Some(&sky_clamp));
|
||||
|
||||
let sky_page = view_stack.add_titled(&sky_scroll, Some("sky"), "Sky");
|
||||
let sky_page = view_stack.add_titled(&sky_container, Some("sky"), "Sky");
|
||||
sky_page.set_icon_name(Some("night-light-symbolic"));
|
||||
let _ = sky_page;
|
||||
|
||||
@@ -840,7 +841,6 @@ fn build_main_page(
|
||||
.build();
|
||||
toolbar_view.add_bottom_bar(&switcher_bar);
|
||||
|
||||
// Call bar (above switcher — added after so it sits closer to content)
|
||||
let call_bar = gtk::Box::new(gtk::Orientation::Horizontal, 10);
|
||||
call_bar.add_css_class("toolbar");
|
||||
call_bar.set_margin_start(8);
|
||||
@@ -869,10 +869,13 @@ fn build_main_page(
|
||||
|
||||
toolbar_view.add_bottom_bar(&call_bar);
|
||||
|
||||
// Ignore unused model reference (kept for possible future use).
|
||||
// Wrap in NavigationView so peer pages can be pushed onto the stack.
|
||||
let nav_view = adw::NavigationView::new();
|
||||
nav_view.push(&adw::NavigationPage::new(&toolbar_view, "Zodia"));
|
||||
|
||||
let _ = model;
|
||||
|
||||
(toolbar_view, natal_list, transit_list, peers_page, peer_count_label,
|
||||
(nav_view, chart_container, sky_container, peers_page, peer_count_label,
|
||||
node_id_label, peers_scrolled, call_bar, call_status, accept_btn, hangup_btn)
|
||||
}
|
||||
|
||||
|
||||
+16
-220
@@ -1,14 +1,9 @@
|
||||
//! Aspect list — one `adw::ActionRow` per astrological aspect, each tappable
|
||||
//! to open an interpretation dialog (view, affirm, contribute).
|
||||
//! Aspect item constructors — data layer for the aspect views.
|
||||
//!
|
||||
//! Converts core aspect types into `AspectItem` slices consumed by
|
||||
//! `aspect_view::AspectView`. No GTK dependency; pure data.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use libadwaita as adw;
|
||||
use libadwaita::gtk;
|
||||
use libadwaita::prelude::*;
|
||||
use zodia_core::{Aspect, HouseTransit, InterpKey, TransitAspect};
|
||||
use zodia_store::ZodiaStore;
|
||||
use zodia_core::{Aspect, HouseTransit, InterpKey, SynastryAspect, TransitAspect};
|
||||
|
||||
// ── row data ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -56,214 +51,15 @@ pub fn transit_items(
|
||||
items
|
||||
}
|
||||
|
||||
// ── list builder ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build a `gtk::ListBox` (boxed-list style) from a slice of aspect items.
|
||||
///
|
||||
/// Each row opens an interpretation dialog on activation. The `parent`
|
||||
/// window is used as the dialog's `transient-for` target.
|
||||
pub fn build_list(
|
||||
items: Vec<AspectItem>,
|
||||
store: Rc<RefCell<ZodiaStore>>,
|
||||
author_pk: [u8; 32],
|
||||
parent: &adw::ApplicationWindow,
|
||||
) -> gtk::ListBox {
|
||||
let list = gtk::ListBox::new();
|
||||
list.add_css_class("boxed-list");
|
||||
list.set_selection_mode(gtk::SelectionMode::None);
|
||||
|
||||
if items.is_empty() {
|
||||
let row = adw::ActionRow::new();
|
||||
row.set_title("No aspects within default orbs");
|
||||
list.append(&row);
|
||||
return list;
|
||||
}
|
||||
|
||||
for item in items {
|
||||
let top_body = store
|
||||
.borrow()
|
||||
.top_body(&item.key)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
let row = adw::ActionRow::new();
|
||||
row.set_title(&item.key.plain_name());
|
||||
|
||||
// Subtitle: interpretation excerpt, or a dimmed placeholder
|
||||
if top_body.is_empty() {
|
||||
row.set_subtitle("No interpretation yet — tap to contribute");
|
||||
} else {
|
||||
row.set_subtitle(&truncate(&top_body, 90));
|
||||
}
|
||||
|
||||
// Right-hand glyph label
|
||||
let glyph_lbl = gtk::Label::new(Some(&item.glyph_suffix));
|
||||
glyph_lbl.add_css_class("dim-label");
|
||||
glyph_lbl.add_css_class("caption");
|
||||
glyph_lbl.add_css_class("aspect-list");
|
||||
row.add_suffix(&glyph_lbl);
|
||||
|
||||
// Chevron affordance
|
||||
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||
row.set_activatable(true);
|
||||
|
||||
// Tap → interpretation dialog
|
||||
let store_c = Rc::clone(&store);
|
||||
let key = item.key.clone();
|
||||
let parent_c = parent.clone();
|
||||
row.connect_activated(move |_| {
|
||||
show_interp_dialog(&parent_c, key.clone(), Rc::clone(&store_c), author_pk);
|
||||
});
|
||||
|
||||
list.append(&row);
|
||||
}
|
||||
|
||||
list
|
||||
}
|
||||
|
||||
// ── interpretation dialog ─────────────────────────────────────────────────────
|
||||
|
||||
fn show_interp_dialog(
|
||||
parent: &adw::ApplicationWindow,
|
||||
key: InterpKey,
|
||||
store: Rc<RefCell<ZodiaStore>>,
|
||||
author_pk: [u8; 32],
|
||||
) {
|
||||
let dialog = adw::Window::builder()
|
||||
.modal(true)
|
||||
.transient_for(parent)
|
||||
.default_width(480)
|
||||
.default_height(560)
|
||||
.title(&key.plain_name())
|
||||
.build();
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
let header = adw::HeaderBar::new();
|
||||
let title_lbl = gtk::Label::new(Some(&key.plain_name()));
|
||||
title_lbl.add_css_class("title");
|
||||
header.set_title_widget(Some(&title_lbl));
|
||||
toolbar.add_top_bar(&header);
|
||||
|
||||
let scroll = gtk::ScrolledWindow::new();
|
||||
scroll.set_hscrollbar_policy(gtk::PolicyType::Never);
|
||||
scroll.set_vexpand(true);
|
||||
|
||||
let clamp = adw::Clamp::new();
|
||||
clamp.set_maximum_size(440);
|
||||
clamp.set_margin_top(16);
|
||||
clamp.set_margin_bottom(24);
|
||||
clamp.set_margin_start(16);
|
||||
clamp.set_margin_end(16);
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||
|
||||
// ── existing interpretations ──────────────────────────────────────────────
|
||||
|
||||
let interp_group = adw::PreferencesGroup::new();
|
||||
interp_group.set_title("Interpretations");
|
||||
|
||||
let existing = store.borrow().all_for_key(&key).unwrap_or_default();
|
||||
|
||||
if existing.is_empty() {
|
||||
let placeholder = adw::ActionRow::new();
|
||||
placeholder.set_title("No interpretations yet");
|
||||
placeholder.set_subtitle("Be the first to contribute below.");
|
||||
interp_group.add(&placeholder);
|
||||
} else {
|
||||
for row_data in &existing {
|
||||
let interp_row = adw::ActionRow::new();
|
||||
interp_row.set_title(&row_data.body);
|
||||
interp_row.set_subtitle(&if row_data.is_baseline {
|
||||
format!("Baseline · {} ♡", row_data.affirmation_count)
|
||||
} else {
|
||||
format!("{} ♡ · community", row_data.affirmation_count)
|
||||
});
|
||||
|
||||
let affirm_btn = gtk::Button::from_icon_name("emblem-favorite-symbolic");
|
||||
affirm_btn.add_css_class("flat");
|
||||
affirm_btn.set_tooltip_text(Some("Affirm this interpretation"));
|
||||
affirm_btn.set_valign(gtk::Align::Center);
|
||||
|
||||
let store_c = Rc::clone(&store);
|
||||
let log_id = row_data.log_id;
|
||||
let row_ref = interp_row.clone();
|
||||
affirm_btn.connect_clicked(move |_| {
|
||||
if let Ok(true) = store_c.borrow().affirm(&log_id, &author_pk) {
|
||||
if let Ok(n) = store_c.borrow().affirmation_count(&log_id) {
|
||||
row_ref.set_subtitle(&format!("{n} ♡ · affirmed"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interp_row.add_suffix(&affirm_btn);
|
||||
interp_group.add(&interp_row);
|
||||
}
|
||||
}
|
||||
|
||||
content.append(&interp_group);
|
||||
|
||||
// ── contribute ────────────────────────────────────────────────────────────
|
||||
|
||||
let contribute_group = adw::PreferencesGroup::new();
|
||||
contribute_group.set_title("Contribute");
|
||||
contribute_group.set_description(Some(
|
||||
"Share your lived understanding of this placement.",
|
||||
));
|
||||
|
||||
let entry = adw::EntryRow::new();
|
||||
entry.set_title("Your interpretation…");
|
||||
contribute_group.add(&entry);
|
||||
content.append(&contribute_group);
|
||||
|
||||
let submit = gtk::Button::with_label("Submit ✓");
|
||||
submit.add_css_class("suggested-action");
|
||||
submit.add_css_class("pill");
|
||||
submit.set_halign(gtk::Align::End);
|
||||
submit.set_margin_top(4);
|
||||
|
||||
let store_c = Rc::clone(&store);
|
||||
let entry_c = entry.clone();
|
||||
let key_c = key.clone();
|
||||
let group_c = interp_group.clone();
|
||||
submit.connect_clicked(move |_| {
|
||||
let text = entry_c.text().to_string();
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(_log_id) = store_c.borrow().insert_interpretation(
|
||||
&key_c,
|
||||
trimmed,
|
||||
Some(&author_pk),
|
||||
false,
|
||||
) {
|
||||
// Append the new row immediately so the user sees it.
|
||||
let new_row = adw::ActionRow::new();
|
||||
new_row.set_title(trimmed);
|
||||
new_row.set_subtitle("0 ♡ · community (just added)");
|
||||
group_c.add(&new_row);
|
||||
entry_c.set_text("");
|
||||
}
|
||||
});
|
||||
|
||||
content.append(&submit);
|
||||
|
||||
clamp.set_child(Some(&content));
|
||||
scroll.set_child(Some(&clamp));
|
||||
toolbar.set_content(Some(&scroll));
|
||||
dialog.set_content(Some(&toolbar));
|
||||
dialog.present();
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn truncate(s: &str, max_chars: usize) -> String {
|
||||
let mut chars = s.chars();
|
||||
let head: String = chars.by_ref().take(max_chars).collect();
|
||||
if chars.next().is_some() {
|
||||
format!("{head}…")
|
||||
} else {
|
||||
head
|
||||
}
|
||||
pub fn synastry_items(aspects: &[SynastryAspect]) -> Vec<AspectItem> {
|
||||
aspects
|
||||
.iter()
|
||||
.map(|a| AspectItem {
|
||||
key: InterpKey::from_synastry(a),
|
||||
glyph_suffix: format!(
|
||||
"{}{}{} orb {:.1}°",
|
||||
a.body_a.symbol(), a.kind.symbol(), a.body_b.symbol(), a.orb
|
||||
),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Aspect + interpretation view.
|
||||
//!
|
||||
//! An `adw::NavigationView` with two pages:
|
||||
//! 1. **List page** — full-width boxed-list of aspect rows. No split, no
|
||||
//! sidebar — one column of text at any window width.
|
||||
//! 2. **Detail page** — pushed on row tap; shows all interpretations with
|
||||
//! affirm buttons and a contribute form. Has its own HeaderBar so the
|
||||
//! back button is always present.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use libadwaita as adw;
|
||||
use libadwaita::gtk;
|
||||
use libadwaita::prelude::*;
|
||||
use zodia_core::InterpKey;
|
||||
use zodia_store::ZodiaStore;
|
||||
|
||||
use crate::aspect_list::AspectItem;
|
||||
|
||||
// ── public entry point ────────────────────────────────────────────────────────
|
||||
|
||||
pub struct AspectView {
|
||||
nav: adw::NavigationView,
|
||||
}
|
||||
|
||||
impl AspectView {
|
||||
/// Standard aspect list — no preamble. Used for Sky (transits) and synastry.
|
||||
pub fn new(
|
||||
items: Vec<AspectItem>,
|
||||
store: Rc<RefCell<ZodiaStore>>,
|
||||
author_pk: [u8; 32],
|
||||
) -> Self {
|
||||
Self::build(items, store, author_pk, None)
|
||||
}
|
||||
|
||||
/// Natal chart view — prepends a placements section (planets in signs/houses)
|
||||
/// above the aspect list inside the same scroll.
|
||||
pub fn natal(
|
||||
items: Vec<AspectItem>,
|
||||
chart: &zodia_core::Chart,
|
||||
store: Rc<RefCell<ZodiaStore>>,
|
||||
author_pk: [u8; 32],
|
||||
) -> Self {
|
||||
let preamble = crate::placements::build_placements_group(chart);
|
||||
Self::build(items, store, author_pk, Some(preamble.upcast::<gtk::Widget>()))
|
||||
}
|
||||
|
||||
fn build(
|
||||
items: Vec<AspectItem>,
|
||||
store: Rc<RefCell<ZodiaStore>>,
|
||||
author_pk: [u8; 32],
|
||||
preamble: Option<gtk::Widget>,
|
||||
) -> Self {
|
||||
let nav = adw::NavigationView::new();
|
||||
nav.set_vexpand(true);
|
||||
nav.push(&list_page(&items, &nav, Rc::clone(&store), author_pk, preamble));
|
||||
Self { nav }
|
||||
}
|
||||
|
||||
pub fn widget(&self) -> &adw::NavigationView {
|
||||
&self.nav
|
||||
}
|
||||
}
|
||||
|
||||
// ── list page ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn list_page(
|
||||
items: &[AspectItem],
|
||||
nav: &adw::NavigationView,
|
||||
store: Rc<RefCell<ZodiaStore>>,
|
||||
author_pk: [u8; 32],
|
||||
preamble: Option<gtk::Widget>,
|
||||
) -> adw::NavigationPage {
|
||||
let scroll = gtk::ScrolledWindow::new();
|
||||
scroll.set_hscrollbar_policy(gtk::PolicyType::Never);
|
||||
scroll.set_vexpand(true);
|
||||
|
||||
let clamp = adw::Clamp::new();
|
||||
clamp.set_maximum_size(720);
|
||||
clamp.set_margin_top(8);
|
||||
clamp.set_margin_bottom(8);
|
||||
clamp.set_margin_start(12);
|
||||
clamp.set_margin_end(12);
|
||||
|
||||
let list = gtk::ListBox::new();
|
||||
list.add_css_class("boxed-list");
|
||||
list.set_selection_mode(gtk::SelectionMode::None);
|
||||
|
||||
if items.is_empty() {
|
||||
let row = adw::ActionRow::new();
|
||||
row.set_title("No aspects within default orbs");
|
||||
list.append(&row);
|
||||
} else {
|
||||
for item in items {
|
||||
let top_body = store
|
||||
.borrow()
|
||||
.top_body(&item.key)
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
|
||||
let row = adw::ActionRow::new();
|
||||
row.set_title(&item.key.plain_name());
|
||||
if top_body.is_empty() {
|
||||
row.set_subtitle("No interpretation yet — tap to contribute");
|
||||
} else {
|
||||
row.set_subtitle(&truncate(&top_body, 120));
|
||||
}
|
||||
|
||||
let glyph_lbl = gtk::Label::new(Some(&item.glyph_suffix));
|
||||
glyph_lbl.add_css_class("dim-label");
|
||||
glyph_lbl.add_css_class("caption");
|
||||
glyph_lbl.add_css_class("aspect-list");
|
||||
row.add_suffix(&glyph_lbl);
|
||||
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||
row.set_activatable(true);
|
||||
|
||||
let nav_c = nav.clone();
|
||||
let store_c = Rc::clone(&store);
|
||||
let key = item.key.clone();
|
||||
row.connect_activated(move |_| {
|
||||
nav_c.push(&detail_page(&key, Rc::clone(&store_c), author_pk));
|
||||
});
|
||||
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(w) = preamble {
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||
content.append(&w);
|
||||
content.append(&list);
|
||||
clamp.set_child(Some(&content));
|
||||
} else {
|
||||
clamp.set_child(Some(&list));
|
||||
}
|
||||
scroll.set_child(Some(&clamp));
|
||||
adw::NavigationPage::new(&scroll, "Aspects")
|
||||
}
|
||||
|
||||
// ── detail page ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Build the interpretation detail page for `key`.
|
||||
///
|
||||
/// Wrapped in a `ToolbarView` + `HeaderBar` so the auto-inserted back button
|
||||
/// is always visible when this page is on top of the navigation stack.
|
||||
pub fn detail_page(
|
||||
key: &InterpKey,
|
||||
store: Rc<RefCell<ZodiaStore>>,
|
||||
author_pk: [u8; 32],
|
||||
) -> adw::NavigationPage {
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
// This header is inside a NavigationView, not a root window — suppress the
|
||||
// window close/min/max buttons so they don't duplicate the outer ones.
|
||||
header.set_show_start_title_buttons(false);
|
||||
header.set_show_end_title_buttons(false);
|
||||
let title = gtk::Label::new(Some(&key.plain_name()));
|
||||
title.add_css_class("title");
|
||||
header.set_title_widget(Some(&title));
|
||||
toolbar.add_top_bar(&header);
|
||||
|
||||
let scroll = gtk::ScrolledWindow::new();
|
||||
scroll.set_hscrollbar_policy(gtk::PolicyType::Never);
|
||||
scroll.set_vexpand(true);
|
||||
|
||||
let clamp = adw::Clamp::new();
|
||||
clamp.set_maximum_size(640);
|
||||
clamp.set_margin_top(16);
|
||||
clamp.set_margin_bottom(24);
|
||||
clamp.set_margin_start(16);
|
||||
clamp.set_margin_end(16);
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||
|
||||
// ── existing interpretations ──────────────────────────────────────────────
|
||||
|
||||
let interp_group = adw::PreferencesGroup::new();
|
||||
interp_group.set_title("Interpretations");
|
||||
|
||||
let existing = store.borrow().all_for_key(key).unwrap_or_default();
|
||||
|
||||
if existing.is_empty() {
|
||||
let row = adw::ActionRow::new();
|
||||
row.set_title("No interpretations yet");
|
||||
row.set_subtitle("Be the first to contribute below.");
|
||||
interp_group.add(&row);
|
||||
} else {
|
||||
for row_data in &existing {
|
||||
let interp_row = adw::ActionRow::new();
|
||||
interp_row.set_title(&row_data.body);
|
||||
interp_row.set_subtitle(&if row_data.is_baseline {
|
||||
format!("Baseline · {} ♡", row_data.affirmation_count)
|
||||
} else {
|
||||
format!("{} ♡ · community", row_data.affirmation_count)
|
||||
});
|
||||
|
||||
let affirm_btn = gtk::Button::from_icon_name("emblem-favorite-symbolic");
|
||||
affirm_btn.add_css_class("flat");
|
||||
affirm_btn.set_tooltip_text(Some("Affirm this interpretation"));
|
||||
affirm_btn.set_valign(gtk::Align::Center);
|
||||
|
||||
let store_c = Rc::clone(&store);
|
||||
let log_id = row_data.log_id;
|
||||
let row_ref = interp_row.clone();
|
||||
affirm_btn.connect_clicked(move |_| {
|
||||
if let Ok(true) = store_c.borrow().affirm(&log_id, &author_pk) {
|
||||
if let Ok(n) = store_c.borrow().affirmation_count(&log_id) {
|
||||
row_ref.set_subtitle(&format!("{n} ♡ · affirmed"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interp_row.add_suffix(&affirm_btn);
|
||||
interp_group.add(&interp_row);
|
||||
}
|
||||
}
|
||||
|
||||
content.append(&interp_group);
|
||||
|
||||
// ── contribute ────────────────────────────────────────────────────────────
|
||||
|
||||
let contribute_group = adw::PreferencesGroup::new();
|
||||
contribute_group.set_title("Contribute");
|
||||
contribute_group.set_description(Some(
|
||||
"Share your lived understanding of this placement.",
|
||||
));
|
||||
|
||||
let entry = adw::EntryRow::new();
|
||||
entry.set_title("Your interpretation…");
|
||||
contribute_group.add(&entry);
|
||||
content.append(&contribute_group);
|
||||
|
||||
let submit = gtk::Button::with_label("Submit ✓");
|
||||
submit.add_css_class("suggested-action");
|
||||
submit.add_css_class("pill");
|
||||
submit.set_halign(gtk::Align::End);
|
||||
submit.set_margin_top(4);
|
||||
|
||||
let store_c = Rc::clone(&store);
|
||||
let entry_c = entry.clone();
|
||||
let key_c = key.clone();
|
||||
let group_c = interp_group.clone();
|
||||
submit.connect_clicked(move |_| {
|
||||
let text = entry_c.text().to_string();
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(_log_id) = store_c.borrow().insert_interpretation(
|
||||
&key_c,
|
||||
trimmed,
|
||||
Some(&author_pk),
|
||||
false,
|
||||
) {
|
||||
let new_row = adw::ActionRow::new();
|
||||
new_row.set_title(trimmed);
|
||||
new_row.set_subtitle("0 ♡ · community (just added)");
|
||||
group_c.add(&new_row);
|
||||
entry_c.set_text("");
|
||||
}
|
||||
});
|
||||
|
||||
content.append(&submit);
|
||||
clamp.set_child(Some(&content));
|
||||
scroll.set_child(Some(&clamp));
|
||||
toolbar.set_content(Some(&scroll));
|
||||
|
||||
adw::NavigationPage::new(&toolbar, &key.plain_name())
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub(crate) fn truncate(s: &str, max_chars: usize) -> String {
|
||||
let mut chars = s.chars();
|
||||
let head: String = chars.by_ref().take(max_chars).collect();
|
||||
if chars.next().is_some() { format!("{head}…") } else { head }
|
||||
}
|
||||
@@ -5,7 +5,10 @@
|
||||
|
||||
mod app;
|
||||
mod aspect_list;
|
||||
mod aspect_view;
|
||||
mod peer_list;
|
||||
mod peer_page;
|
||||
mod placements;
|
||||
mod util;
|
||||
|
||||
use app::{AppInit, AppModel};
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Connected-peer navigation page.
|
||||
//!
|
||||
//! Pushed onto the app's `adw::NavigationView` when a Tier-1 exchange
|
||||
//! completes. Shows:
|
||||
//! - A header bar with the peer's solar sign glyph, truncated node ID,
|
||||
//! and a call button.
|
||||
//! - An `AspectView` (adaptive NavigationSplitView) populated with
|
||||
//! cross-chart synastry aspects computed from the peer's exact birth data.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use libadwaita as adw;
|
||||
use libadwaita::gtk;
|
||||
use libadwaita::prelude::*;
|
||||
use relm4::AsyncComponentSender;
|
||||
use zodia_core::{compute_positions, compute_synastry, Chart};
|
||||
use zodia_net::{PeerId, Tier1Blob};
|
||||
use zodia_store::ZodiaStore;
|
||||
|
||||
use crate::app::{AppModel, AppMsg};
|
||||
use crate::aspect_list::synastry_items;
|
||||
use crate::aspect_view::AspectView;
|
||||
use crate::util::sign_glyph;
|
||||
|
||||
/// Build the `adw::NavigationPage` for a connected peer.
|
||||
///
|
||||
/// Synastry is computed here from `their_blob.birth` and `our_chart.positions`.
|
||||
/// If ephemeris fails for the peer's JDN, an empty aspect list is shown.
|
||||
pub fn build_peer_page(
|
||||
peer_id: &PeerId,
|
||||
their_blob: &Tier1Blob,
|
||||
our_chart: &Chart,
|
||||
store: Rc<RefCell<ZodiaStore>>,
|
||||
author_pk: [u8; 32],
|
||||
sender: &AsyncComponentSender<AppModel>,
|
||||
) -> adw::NavigationPage {
|
||||
let peer_hex = hex::encode_upper(&peer_id.0[..4]);
|
||||
|
||||
// ── synastry computation ──────────────────────────────────────────────────
|
||||
|
||||
let synastry = match compute_positions(their_blob.birth.jdn) {
|
||||
Ok(their_pos) => compute_synastry(&our_chart.positions, &their_pos),
|
||||
Err(e) => {
|
||||
tracing::warn!(peer = %peer_hex, "synastry computation failed: {e}");
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
let items = synastry_items(&synastry);
|
||||
|
||||
// ── layout ────────────────────────────────────────────────────────────────
|
||||
|
||||
let toolbar_view = adw::ToolbarView::new();
|
||||
|
||||
// Header bar
|
||||
let header = adw::HeaderBar::new();
|
||||
header.set_show_start_title_buttons(false);
|
||||
header.set_show_end_title_buttons(false);
|
||||
|
||||
let their_solar_month = zodia_core::solar_month(their_blob.birth.jdn);
|
||||
let glyph = sign_glyph(their_solar_month);
|
||||
let title_lbl = gtk::Label::new(Some(&format!("{glyph} ···{peer_hex}")));
|
||||
title_lbl.add_css_class("title");
|
||||
header.set_title_widget(Some(&title_lbl));
|
||||
|
||||
let call_btn = gtk::Button::from_icon_name("call-start-symbolic");
|
||||
call_btn.add_css_class("suggested-action");
|
||||
call_btn.add_css_class("circular");
|
||||
call_btn.set_tooltip_text(Some("Start voice call"));
|
||||
|
||||
let pid = peer_id.clone();
|
||||
let s = sender.clone();
|
||||
call_btn.connect_clicked(move |_| s.input(AppMsg::CallPeer(pid.clone())));
|
||||
header.pack_end(&call_btn);
|
||||
|
||||
toolbar_view.add_top_bar(&header);
|
||||
|
||||
// Synastry aspect view
|
||||
let av = AspectView::new(items, store, author_pk);
|
||||
av.widget().set_vexpand(true);
|
||||
toolbar_view.set_content(Some(av.widget()));
|
||||
|
||||
adw::NavigationPage::new(&toolbar_view, &format!("···{peer_hex}"))
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Chart placement widget — planets, ASC, and MC in sign and house.
|
||||
|
||||
use libadwaita as adw;
|
||||
use libadwaita::prelude::*;
|
||||
use zodia_core::{Chart, Planet};
|
||||
|
||||
use crate::util::{lon_to_sign_deg, sign_glyph, sign_name};
|
||||
|
||||
/// Build a `PreferencesGroup` listing every planet's sign placement and house,
|
||||
/// followed by the Ascendant and Midheaven.
|
||||
///
|
||||
/// If the house system is a stub (all cusps zero — happens when the geohash is
|
||||
/// too short to resolve coordinates), house numbers are omitted.
|
||||
pub fn build_placements_group(chart: &Chart) -> adw::PreferencesGroup {
|
||||
let group = adw::PreferencesGroup::new();
|
||||
group.set_title("Placements");
|
||||
|
||||
let is_stub = chart.houses.cusps.iter().all(|&c| c == 0.0);
|
||||
|
||||
// ── planets ───────────────────────────────────────────────────────────────
|
||||
|
||||
for &planet in Planet::all() {
|
||||
let Some(lon) = chart.positions.get(planet) else { continue };
|
||||
let (sign_idx, deg_str) = lon_to_sign_deg(lon);
|
||||
let house = chart.houses.house_of(lon);
|
||||
|
||||
let row = adw::ActionRow::new();
|
||||
row.set_title(&format!(
|
||||
"{} {} in {} {}",
|
||||
planet.symbol(),
|
||||
capitalize(planet.name()),
|
||||
sign_glyph(sign_idx),
|
||||
sign_name(sign_idx),
|
||||
));
|
||||
row.set_subtitle(&if is_stub {
|
||||
deg_str
|
||||
} else {
|
||||
format!("{} · House {house}", deg_str)
|
||||
});
|
||||
group.add(&row);
|
||||
}
|
||||
|
||||
// ── angles ────────────────────────────────────────────────────────────────
|
||||
|
||||
let (asc_sign, asc_deg) = lon_to_sign_deg(chart.houses.ascendant);
|
||||
let asc_row = adw::ActionRow::new();
|
||||
asc_row.set_title(&format!(
|
||||
"Ascendant in {} {}",
|
||||
sign_glyph(asc_sign),
|
||||
sign_name(asc_sign),
|
||||
));
|
||||
asc_row.set_subtitle(&format!("{} · rising sign", asc_deg));
|
||||
group.add(&asc_row);
|
||||
|
||||
let (mc_sign, mc_deg) = lon_to_sign_deg(chart.houses.midheaven);
|
||||
let mc_row = adw::ActionRow::new();
|
||||
mc_row.set_title(&format!(
|
||||
"Midheaven in {} {}",
|
||||
sign_glyph(mc_sign),
|
||||
sign_name(mc_sign),
|
||||
));
|
||||
mc_row.set_subtitle(&format!("{} · culminating", mc_deg));
|
||||
group.add(&mc_row);
|
||||
|
||||
group
|
||||
}
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
match c.next() {
|
||||
None => String::new(),
|
||||
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,26 @@ pub fn sign_glyph(solar_month: u8) -> &'static str {
|
||||
SIGNS.get(solar_month as usize % 12).copied().unwrap_or("?")
|
||||
}
|
||||
|
||||
/// Zodiac sign name from a sign index (0 = Aries … 11 = Pisces).
|
||||
pub fn sign_name(idx: u8) -> &'static str {
|
||||
const NAMES: [&str; 12] = [
|
||||
"Aries", "Taurus", "Gemini", "Cancer",
|
||||
"Leo", "Virgo", "Libra", "Scorpio",
|
||||
"Sagittarius", "Capricorn", "Aquarius", "Pisces",
|
||||
];
|
||||
NAMES.get(idx as usize % 12).copied().unwrap_or("?")
|
||||
}
|
||||
|
||||
/// Ecliptic longitude → (sign_index 0–11, formatted degree string "17°23′").
|
||||
pub fn lon_to_sign_deg(lon: f64) -> (u8, String) {
|
||||
let lon = lon.rem_euclid(360.0);
|
||||
let sign_idx = (lon / 30.0).floor() as u8 % 12;
|
||||
let within = lon % 30.0;
|
||||
let deg = within.floor() as u32;
|
||||
let min = ((within - deg as f64) * 60.0).round() as u32;
|
||||
(sign_idx, format!("{deg}°{min:02}′"))
|
||||
}
|
||||
|
||||
// ── aspect card formatters ────────────────────────────────────────────────────
|
||||
|
||||
/// Multi-line card for a natal aspect — kept for future synastry/export views.
|
||||
|
||||
Reference in New Issue
Block a user