Crosspost targets: one row when they fit, stacked otherwise
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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,9 +77,14 @@ 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() => {
|
||||
if targets_fit_inline(&app.targets, area.width.saturating_sub(2)) {
|
||||
3
|
||||
} else {
|
||||
(app.targets.len() as u16 + 2).min(8)
|
||||
}
|
||||
}
|
||||
_ => 3,
|
||||
};
|
||||
let [title, reply, content, tags, targets] = Layout::vertical([
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user