commits
tags
#include "ui.h"
#include "../util.h"
#include <curses.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#define SIDEBAR_W 16
#define FOOTER_H 2
static const char *STATUS_LABEL[] = {
"Stopped", "Queued", "Checking", "Queued",
"Downloading", "Queued", "Seeding",
};
typedef struct {
const char *label;
FilterMode filter;
} SidebarItem;
static const SidebarItem SIDEBAR_ITEMS[] = {
{"All", FILTER_ALL},
{"Downloading", FILTER_DOWNLOADING},
{"Uploading", FILTER_UPLOADING},
{"Paused", FILTER_PAUSED},
{"Completed", FILTER_COMPLETED},
};
#define SIDEBAR_ITEM_COUNT (int)(sizeof(SIDEBAR_ITEMS) / sizeof(SIDEBAR_ITEMS[0]))
static const char *SORT_LABEL[SORT_COUNT] = {
"Name", "Size", "Progress", "Status", "Down", "Up", "Ratio", "ETA",
};
static void draw_title(AppState *st, int cols)
{
attron(COLOR_PAIR(CP_HEADER) | A_BOLD);
mvhline(0, 0, ' ', cols);
mvprintw(0, 0, " TransTUI \xe2\x80\xa2 %s:%d \xe2\x80\xa2 %s", st->cfg->host, st->cfg->port,
st->status_msg[0] && time(NULL) < st->status_msg_until ? "..." : "Connected");
if (st->search[0]) {
int x = cols - (int)strlen(st->search) - 12;
if (x > 20)
mvprintw(0, x, "Search: %s", st->search);
}
attroff(COLOR_PAIR(CP_HEADER) | A_BOLD);
}
static void draw_sidebar(AppState *st, int y, int x, int h, int w)
{
WINDOW *win = derwin(stdscr, h, w, y, x);
if (st->focus == FOCUS_SIDEBAR)
wattron(win, A_BOLD);
ui_box(win, h, w);
if (st->focus == FOCUS_SIDEBAR)
wattroff(win, A_BOLD);
ui_box_title(win, w, "Filter");
for (int i = 0; i < SIDEBAR_ITEM_COUNT && i + 2 < h; i++) {
int active = (st->filter == SIDEBAR_ITEMS[i].filter);
int hover = (st->focus == FOCUS_SIDEBAR && st->sidebar_cursor == i);
int attr = 0;
if (hover)
attr = COLOR_PAIR(CP_SELROW) | A_BOLD;
else if (active)
attr = COLOR_PAIR(CP_ACCENT) | A_BOLD;
wattron(win, attr);
mvwprintw(win, i + 2, 1, "%s%-*s", active ? "\xe2\x9d\xaf " : " ", w - 4, SIDEBAR_ITEMS[i].label);
wattroff(win, attr);
}
wnoutrefresh(win);
delwin(win);
}
/* Column x-offsets are computed once here and reused by both the header and
* the row renderer, so the two can never drift out of alignment. */
typedef struct {
int mark, name, size, pct, status, down, up, eta, ratio;
int name_w;
int show_eta, show_ratio;
} ContentCols;
static ContentCols compute_cols(int content_w)
{
ContentCols c;
memset(&c, 0, sizeof(c));
int fixed = 1 + 9 + 1 + 6 + 1 + 11 + 1 + 10 + 1 + 10;
c.show_eta = 1;
c.show_ratio = 1;
c.name_w = content_w - fixed - 2 * 7;
if (c.name_w < 10) {
c.show_ratio = 0;
c.name_w = content_w - fixed - 7;
}
if (c.name_w < 10) {
c.show_eta = 0;
c.name_w = content_w - fixed;
}
if (c.name_w < 6)
c.name_w = 6;
c.mark = 2;
c.name = c.mark + 1;
c.size = c.name + c.name_w + 1;
c.pct = c.size + 9 + 1;
c.status = c.pct + 6 + 1;
c.down = c.status + 11 + 1;
c.up = c.down + 10 + 1;
c.eta = c.up + 10 + 1;
c.ratio = c.eta + (c.show_eta ? 7 : 0);
return c;
}
static void draw_content_header(WINDOW *win, const ContentCols *c)
{
wattron(win, A_BOLD);
mvwprintw(win, 1, c->name, "%-*.*s", c->name_w, c->name_w, "Name");
mvwprintw(win, 1, c->size, "%9s", "Size");
mvwprintw(win, 1, c->pct, "%6s", "%");
mvwprintw(win, 1, c->status, "%-11s", "Status");
mvwprintw(win, 1, c->down, "%10s", "Down");
mvwprintw(win, 1, c->up, "%10s", "Up");
if (c->show_eta)
mvwprintw(win, 1, c->eta, "%6s", "ETA");
if (c->show_ratio)
mvwprintw(win, 1, c->ratio, "%6s", "Ratio");
wattroff(win, A_BOLD);
}
static void draw_content_row(WINDOW *win, int y, const ContentCols *c, const Torrent *t, int is_cursor)
{
char sizebuf[24], downbuf[24], upbuf[24], etabuf[16], ratiobuf[16];
fmt_size(t->total_size, sizebuf, sizeof(sizebuf));
fmt_speed(t->rate_download, downbuf, sizeof(downbuf));
fmt_speed(t->rate_upload, upbuf, sizeof(upbuf));
fmt_eta(t->eta, etabuf, sizeof(etabuf));
fmt_ratio(t->upload_ratio, ratiobuf, sizeof(ratiobuf));
const char *status = t->error ? "Error" : STATUS_LABEL[t->status];
int attr = is_cursor ? (COLOR_PAIR(CP_SELROW) | A_BOLD)
: COLOR_PAIR(t->error ? CP_ERROR : ui_color_for_status(t->status));
/* Blank just the interior (not wclrtoeol - that would also wipe the
* box's right border character on this row). */
mvwhline(win, y, 1, ' ', getmaxx(win) - 2);
wattron(win, attr);
mvwprintw(win, y, c->mark, "%c", t->selected ? '*' : ' ');
mvwprintw(win, y, c->name, "%-*.*s", c->name_w, c->name_w, t->name);
mvwprintw(win, y, c->size, "%9s", sizebuf);
mvwprintw(win, y, c->pct, "%5.1f%%", t->percent_done * 100.0);
mvwprintw(win, y, c->status, "%-11s", status);
mvwprintw(win, y, c->down, "%10s", downbuf);
mvwprintw(win, y, c->up, "%10s", upbuf);
if (c->show_eta)
mvwprintw(win, y, c->eta, "%6s", etabuf);
if (c->show_ratio)
mvwprintw(win, y, c->ratio, "%6s", ratiobuf);
wattroff(win, attr);
}
static void draw_content(AppState *st, int y, int x, int h, int w)
{
WINDOW *win = derwin(stdscr, h, w, y, x);
if (st->focus == FOCUS_LIST)
wattron(win, A_BOLD);
ui_box(win, h, w);
if (st->focus == FOCUS_LIST)
wattroff(win, A_BOLD);
char title[64];
const char *filter_label = "All";
for (int i = 0; i < SIDEBAR_ITEM_COUNT; i++)
if (SIDEBAR_ITEMS[i].filter == st->filter)
filter_label = SIDEBAR_ITEMS[i].label;
snprintf(title, sizeof(title), "%s (%zu)", filter_label, st->order_count);
ui_box_title(win, w, title);
ContentCols cols = compute_cols(w - 4);
draw_content_header(win, &cols);
int list_top = 2;
int list_h = h - list_top - 1;
if (list_h < 0)
list_h = 0;
if (st->cursor < st->top)
st->top = st->cursor;
if (st->cursor >= st->top + list_h)
st->top = st->cursor - list_h + 1;
if (st->top < 0)
st->top = 0;
for (int row = 0; row < list_h; row++) {
int idx = st->top + row;
if ((size_t)idx >= st->order_count)
break;
const Torrent *t = &st->list.items[st->order[idx]];
int is_cursor = (idx == st->cursor) && st->focus == FOCUS_LIST;
draw_content_row(win, list_top + row, &cols, t, is_cursor);
}
wnoutrefresh(win);
delwin(win);
}
static void draw_status_line(AppState *st, int y, int cols)
{
move(y, 0);
clrtoeol();
if (st->status_msg[0] && time(NULL) < st->status_msg_until) {
attron(COLOR_PAIR(CP_ACCENT));
mvprintw(y, 1, "%.*s", cols - 2, st->status_msg);
attroff(COLOR_PAIR(CP_ACCENT));
return;
}
int64_t down = 0, up = 0;
for (size_t i = 0; i < st->list.count; i++) {
down += st->list.items[i].rate_download;
up += st->list.items[i].rate_upload;
}
char downbuf[24], upbuf[24];
fmt_speed(down, downbuf, sizeof(downbuf));
fmt_speed(up, upbuf, sizeof(upbuf));
mvprintw(y, 1, "%zu torrents \xe2\x80\xa2 \xe2\x86\x93 %s \xe2\x80\xa2 \xe2\x86\x91 %s \xe2\x80\xa2 sort: %s",
st->list.count, downbuf, upbuf, SORT_LABEL[st->sort_col]);
}
static const KeyHint LIST_HINTS_SIDEBAR[] = {
{"\xe2\x86\x91\xe2\x86\x93", "Filter"}, {"Tab", "Switch focus"}, {"Enter", "Apply"},
{"g", "Settings"}, {"?", "Help"}, {"q", "Quit"},
};
static const KeyHint LIST_HINTS_LIST[] = {
{"\xe2\x86\x91\xe2\x86\x93", "Navigate"}, {"Tab", "Filter"}, {"Enter", "Details"},
{"Space", "Select"}, {"s/S/p", "Start/Stop"}, {"v", "Verify"},
{"a", "Add"}, {"d", "Remove"}, {"l", "Speed"}, {"o/O", "Sort"},
{"/", "Search"}, {"g", "Settings"}, {"?", "Help"}, {"q", "Quit"},
};
/* Draws the list view into curses' pending-update buffer without flushing
* it (no doupdate()). Used both by ui_list_render() below and by the
* settings popup, which needs to draw this as a backdrop and then layer its
* own box on top before a single atomic doupdate() - otherwise the list-only
* backdrop would flash on screen for one frame before the popup appears. */
void ui_list_render_content(AppState *st)
{
int rows, cols;
getmaxyx(stdscr, rows, cols);
erase();
draw_title(st, cols);
int body_top = 1;
int body_bottom = rows - FOOTER_H - 1 - 1;
int body_h = body_bottom - body_top + 1;
if (body_h < 3)
body_h = 3;
draw_sidebar(st, body_top, 0, body_h, SIDEBAR_W);
int content_x = SIDEBAR_W;
int content_w = cols - SIDEBAR_W;
if (content_w < 20)
content_w = 20;
draw_content(st, body_top, content_x, body_h, content_w);
draw_status_line(st, rows - FOOTER_H - 1, cols);
if (st->focus == FOCUS_SIDEBAR)
ui_draw_footer(LIST_HINTS_SIDEBAR, sizeof(LIST_HINTS_SIDEBAR) / sizeof(LIST_HINTS_SIDEBAR[0]), FOOTER_H);
else
ui_draw_footer(LIST_HINTS_LIST, sizeof(LIST_HINTS_LIST) / sizeof(LIST_HINTS_LIST[0]), FOOTER_H);
}
void ui_list_render(AppState *st)
{
ui_list_render_content(st);
doupdate();
}
static int *collect_target_ids(AppState *st, size_t *out_n)
{
size_t cnt = 0;
for (size_t i = 0; i < st->list.count; i++)
if (st->list.items[i].selected)
cnt++;
if (cnt == 0) {
if (st->order_count == 0) {
*out_n = 0;
return NULL;
}
int *buf = malloc(sizeof(int));
buf[0] = st->list.items[st->order[st->cursor]].id;
*out_n = 1;
return buf;
}
int *buf = malloc(cnt * sizeof(int));
size_t j = 0;
for (size_t i = 0; i < st->list.count; i++)
if (st->list.items[i].selected)
buf[j++] = st->list.items[i].id;
*out_n = cnt;
return buf;
}
static void do_action(AppState *st, const char *method, const char *verb)
{
size_t n;
int *ids = collect_target_ids(st, &n);
if (!ids || n == 0) {
free(ids);
return;
}
char err[256];
if (torrent_action(st->rpc, method, ids, n, err, sizeof(err)) != 0)
ui_set_status(st, "Error: %s", err);
else
ui_set_status(st, "%s %zu torrent(s)", verb, n);
free(ids);
st->need_poll_now = 1;
}
static void do_search(AppState *st)
{
char buf[128];
snprintf(buf, sizeof(buf), "%s", st->search);
if (ui_prompt("Search", buf, buf, sizeof(buf))) {
snprintf(st->search, sizeof(st->search), "%s", buf);
st->cursor = 0;
}
}
static void handle_sidebar_key(AppState *st, int ch)
{
switch (ch) {
case KEY_UP:
case 'k':
if (st->sidebar_cursor > 0) {
st->sidebar_cursor--;
st->filter = SIDEBAR_ITEMS[st->sidebar_cursor].filter;
st->cursor = 0;
}
break;
case KEY_DOWN:
case 'j':
if (st->sidebar_cursor + 1 < SIDEBAR_ITEM_COUNT) {
st->sidebar_cursor++;
st->filter = SIDEBAR_ITEMS[st->sidebar_cursor].filter;
st->cursor = 0;
}
break;
case '\t':
case '\n':
case '\r':
case KEY_ENTER:
case KEY_RIGHT:
st->focus = FOCUS_LIST;
break;
case 'q':
case 'Q':
st->running = 0;
break;
case 'g':
st->view = VIEW_SETTINGS;
break;
case '?':
st->view = VIEW_HELP;
break;
default:
break;
}
}
static void handle_list_key(AppState *st, int ch)
{
int page = 10;
{
int rows, cols;
getmaxyx(stdscr, rows, cols);
(void)cols;
page = rows - 8 > 1 ? rows - 8 : 1;
}
switch (ch) {
case 'q':
case 'Q':
st->running = 0;
break;
case '\t':
case KEY_LEFT:
st->focus = FOCUS_SIDEBAR;
st->sidebar_cursor = st->filter;
break;
case KEY_UP:
case 'k':
if (st->cursor > 0)
st->cursor--;
break;
case KEY_DOWN:
case 'j':
if ((size_t)(st->cursor + 1) < st->order_count)
st->cursor++;
break;
case KEY_PPAGE:
st->cursor -= page;
if (st->cursor < 0)
st->cursor = 0;
break;
case KEY_NPAGE:
st->cursor += page;
if ((size_t)st->cursor >= st->order_count)
st->cursor = st->order_count ? (int)st->order_count - 1 : 0;
break;
case KEY_HOME:
st->cursor = 0;
break;
case KEY_END:
st->cursor = st->order_count ? (int)st->order_count - 1 : 0;
break;
case ' ':
if ((size_t)st->cursor < st->order_count) {
Torrent *t = &st->list.items[st->order[st->cursor]];
t->selected = !t->selected;
if ((size_t)(st->cursor + 1) < st->order_count)
st->cursor++;
}
break;
case '\n':
case '\r':
case KEY_ENTER:
if ((size_t)st->cursor < st->order_count)
ui_open_detail(st, st->list.items[st->order[st->cursor]].id);
break;
case 's':
do_action(st, "torrent-start", "Started");
break;
case 'S':
do_action(st, "torrent-stop", "Stopped");
break;
case 'p': {
if ((size_t)st->cursor < st->order_count) {
const Torrent *t = &st->list.items[st->order[st->cursor]];
do_action(st, t->status == TR_STATUS_STOPPED ? "torrent-start" : "torrent-stop",
t->status == TR_STATUS_STOPPED ? "Started" : "Stopped");
}
break;
}
case 'v':
do_action(st, "torrent-verify", "Verifying");
break;
case 'd':
case KEY_DC: {
size_t n;
int *ids = collect_target_ids(st, &n);
if (ids && n) {
ui_dialog_remove(st, ids, n);
st->need_poll_now = 1;
}
free(ids);
break;
}
case 'l': {
size_t n;
int *ids = collect_target_ids(st, &n);
if (ids && n)
ui_dialog_speed_limit(st, ids, n);
free(ids);
break;
}
case 'a':
ui_dialog_add_torrent(st);
st->need_poll_now = 1;
break;
case 'r':
st->need_poll_now = 1;
break;
case 'o':
st->sort_col = (st->sort_col + 1) % SORT_COUNT;
ui_set_status(st, "Sort: %s", SORT_LABEL[st->sort_col]);
break;
case 'O':
st->sort_desc = !st->sort_desc;
ui_set_status(st, "Sort: %s (%s)", SORT_LABEL[st->sort_col],
st->sort_desc ? "descending" : "ascending");
break;
case '/':
do_search(st);
break;
case 27: /* Esc */
if (st->search[0]) {
st->search[0] = '\0';
st->cursor = 0;
} else {
for (size_t i = 0; i < st->list.count; i++)
st->list.items[i].selected = 0;
}
break;
case 'g':
st->view = VIEW_SETTINGS;
break;
case '?':
st->view = VIEW_HELP;
break;
default:
break;
}
}
void ui_list_handle_key(AppState *st, int ch)
{
if (st->focus == FOCUS_SIDEBAR)
handle_sidebar_key(st, ch);
else
handle_list_key(st, ch);
}