more ui tweaks

This commit is contained in:
Bendik Aagaard Lynghaug
2026-03-19 22:47:26 +01:00
parent bf5cd72f8b
commit b8ee9fbb94
7 changed files with 609 additions and 349 deletions
+132 -129
View File
@@ -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: &gtk::ListBox, dst: &gtk::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
View File
@@ -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(&gtk::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()
}
+280
View File
@@ -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(&gtk::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 }
}
+3
View File
@@ -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};
+84
View File
@@ -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}"))
}
+74
View File
@@ -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(),
}
}
+20
View File
@@ -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 011, 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.