diff --git a/README.md b/README.md index a34215b..cc1a1c0 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Running `tuipub` with no account starts the login flow first. | Ctrl-R | Reload crosspost targets from the server | | Ctrl-O | Open the last published post | | Esc | Quit, or back to the post list while editing (asks if there are unsaved changes) | -| ↑ ↓ / j k, Space, a | Crosspost list: move, toggle, all/none | +| ← → / ↑ ↓, Space, a | Crosspost list: move, toggle, all/none (one row when they fit, stacked when the window is narrow) | **Likes and reposts** (StarPunk 2.5.0+): press Ctrl-T, paste the URL into "Like of" / "Repost of", and optionally add a comment. StarPunk turns likes and reposts of fediverse or Bluesky posts into native favourites/boosts when it crossposts; likes of ordinary web pages aren't crossposted. What a like or repost points at can't be changed afterwards, but its comment can. diff --git a/src/app.rs b/src/app.rs index e0f05d3..acd3765 100644 --- a/src/app.rs +++ b/src/app.rs @@ -792,10 +792,11 @@ impl App { return; } match key.code { - KeyCode::Up | KeyCode::Char('k') => { + // Up/Down for the stacked layout, Left/Right for the single row. + KeyCode::Up | KeyCode::Left | KeyCode::Char('k' | 'h') => { self.target_cursor = self.target_cursor.saturating_sub(1) } - KeyCode::Down | KeyCode::Char('j') => { + KeyCode::Down | KeyCode::Right | KeyCode::Char('j' | 'l') => { self.target_cursor = (self.target_cursor + 1).min(n - 1) } KeyCode::Char(' ') | KeyCode::Enter => { diff --git a/src/ui.rs b/src/ui.rs index a863ddc..f464d08 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -9,7 +9,7 @@ use ratatui::{ }; use crate::app::{App, Discard, Focus, Mode, Posts, Screen, Status, Targets}; -use crate::micropub::{Kind, Post}; +use crate::micropub::{Kind, Post, SyndicationTarget}; const ACCENT: Color = Color::Cyan; const DIM: Color = Color::DarkGray; @@ -77,8 +77,13 @@ fn draw_compose(f: &mut Frame, app: &mut App, area: Rect) -> &'static str { let editing = app.editing.is_some(); let targets_height = match (&app.targets_state, editing) { (_, true) => 3, + // One row when every target fits side by side, else one per line. (Targets::Loaded, false) if !app.targets.is_empty() => { - (app.targets.len() as u16 + 2).min(8) + if targets_fit_inline(&app.targets, area.width.saturating_sub(2)) { + 3 + } else { + (app.targets.len() as u16 + 2).min(8) + } } _ => 3, }; @@ -125,7 +130,7 @@ fn draw_compose(f: &mut Frame, app: &mut App, area: Rect) -> &'static str { match (app.focus, editing) { (Focus::Syndication, _) => { - " ↑↓ move · space toggle · a all/none · Tab next · Ctrl-S publish · Ctrl-P posts · Esc quit" + " ←→/↑↓ move · space toggle · a all/none · Tab next · Ctrl-S publish · Ctrl-P posts · Esc quit" } (_, true) => { " Tab next field · Ctrl-S save · Ctrl-N new note · Ctrl-P posts · Esc back to posts" @@ -164,6 +169,23 @@ fn draw_targets(f: &mut Frame, app: &App, area: Rect) { .block(block), area, ), + Targets::Loaded if targets_fit_inline(&app.targets, area.width.saturating_sub(2)) => { + let mut spans = Vec::new(); + for (i, (t, on)) in app.targets.iter().enumerate() { + if i > 0 { + spans.push(Span::raw(INLINE_GAP)); + } + let mut name = Style::default(); + let mut mark = Style::default().fg(if *on { Color::Green } else { DIM }); + if focused && i == app.target_cursor { + name = name.add_modifier(Modifier::REVERSED); + mark = mark.add_modifier(Modifier::REVERSED); + } + spans.push(Span::styled(if *on { "[x] " } else { "[ ] " }, mark)); + spans.push(Span::styled(t.name.clone(), name)); + } + f.render_widget(Paragraph::new(Line::from(spans)).block(block), area); + } Targets::Loaded => { let items: Vec = app .targets @@ -189,6 +211,18 @@ fn draw_targets(f: &mut Frame, app: &App, area: Rect) { } } +const INLINE_GAP: &str = " "; + +/// Whether "[x] Name [x] Other" fits in `width` columns. +fn targets_fit_inline(targets: &[(SyndicationTarget, bool)], width: u16) -> bool { + let names: usize = targets + .iter() + .map(|(t, _)| 4 + Line::raw(t.name.as_str()).width()) + .sum(); + let gaps = INLINE_GAP.len() * targets.len().saturating_sub(1); + names + gaps <= width as usize +} + /// "2026-11-01T01:02:09.6" -> "2026-11-01 01:02" fn short_date(iso: &str) -> String { let date = iso.get(..10).unwrap_or(iso); @@ -474,6 +508,24 @@ fn centered(area: Rect, width: u16, height: u16) -> Rect { mod tests { use super::*; + #[test] + fn targets_go_inline_when_they_fit() { + let t = |n: &str| { + ( + SyndicationTarget { + uid: n.into(), + name: n.into(), + }, + true, + ) + }; + let targets = [t("GoToSocial"), t("Bluesky")]; + // "[x] GoToSocial" (14) + gap (3) + "[x] Bluesky" (11) = 28 + assert!(targets_fit_inline(&targets, 28)); + assert!(!targets_fit_inline(&targets, 27)); + assert!(targets_fit_inline(&[], 0)); + } + #[test] fn dates_and_headlines() { assert_eq!(short_date("2026-11-01T01:02:09.643392"), "2026-11-01 01:02");