Crosspost targets: one row when they fit, stacked otherwise

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Phil
2026-10-31 20:55:46 -06:00
co-authored by Claude Opus 5.5
parent dfc9b753ac
commit c78ade3900
3 changed files with 59 additions and 6 deletions
+1 -1
View File
@@ -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.
+3 -2
View File
@@ -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 => {
+55 -3
View File
@@ -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<ListItem> = 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");