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-R | Reload crosspost targets from the server |
|
||||||
| Ctrl-O | Open the last published post |
|
| Ctrl-O | Open the last published post |
|
||||||
| Esc | Quit, or back to the post list while editing (asks if there are unsaved changes) |
|
| 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.
|
**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;
|
return;
|
||||||
}
|
}
|
||||||
match key.code {
|
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)
|
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)
|
self.target_cursor = (self.target_cursor + 1).min(n - 1)
|
||||||
}
|
}
|
||||||
KeyCode::Char(' ') | KeyCode::Enter => {
|
KeyCode::Char(' ') | KeyCode::Enter => {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use ratatui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::app::{App, Discard, Focus, Mode, Posts, Screen, Status, Targets};
|
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 ACCENT: Color = Color::Cyan;
|
||||||
const DIM: Color = Color::DarkGray;
|
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 editing = app.editing.is_some();
|
||||||
let targets_height = match (&app.targets_state, editing) {
|
let targets_height = match (&app.targets_state, editing) {
|
||||||
(_, true) => 3,
|
(_, true) => 3,
|
||||||
|
// One row when every target fits side by side, else one per line.
|
||||||
(Targets::Loaded, false) if !app.targets.is_empty() => {
|
(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,
|
_ => 3,
|
||||||
};
|
};
|
||||||
@@ -125,7 +130,7 @@ fn draw_compose(f: &mut Frame, app: &mut App, area: Rect) -> &'static str {
|
|||||||
|
|
||||||
match (app.focus, editing) {
|
match (app.focus, editing) {
|
||||||
(Focus::Syndication, _) => {
|
(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) => {
|
(_, true) => {
|
||||||
" Tab next field · Ctrl-S save · Ctrl-N new note · Ctrl-P posts · Esc back to posts"
|
" 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),
|
.block(block),
|
||||||
area,
|
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 => {
|
Targets::Loaded => {
|
||||||
let items: Vec<ListItem> = app
|
let items: Vec<ListItem> = app
|
||||||
.targets
|
.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"
|
/// "2026-11-01T01:02:09.6" -> "2026-11-01 01:02"
|
||||||
fn short_date(iso: &str) -> String {
|
fn short_date(iso: &str) -> String {
|
||||||
let date = iso.get(..10).unwrap_or(iso);
|
let date = iso.get(..10).unwrap_or(iso);
|
||||||
@@ -474,6 +508,24 @@ fn centered(area: Rect, width: u16, height: u16) -> Rect {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn dates_and_headlines() {
|
fn dates_and_headlines() {
|
||||||
assert_eq!(short_date("2026-11-01T01:02:09.643392"), "2026-11-01 01:02");
|
assert_eq!(short_date("2026-11-01T01:02:09.643392"), "2026-11-01 01:02");
|
||||||
|
|||||||
Reference in New Issue
Block a user