foxygit / TransTUI Log in
commits tags

/src/ui/ui_list.c · 14.96 KB

raw
#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);
}