foxygit / ytmdl Log in
commits tags

/c/src/ui.c · 11.57 KB

raw
#include "ui.h"

#include <string.h>

#include "colors.h"
#include "dirpicker.h"
#include "icons.h"

#define COL_ICON_X 2
#define COL_TITLE_X 5
#define COL_TITLE_W 26
#define COL_FOLDER_W 14
#define COL_PROGRESS_W 16
#define COL_PROGRESS_BAR_W 10
#define COL_SPEED_W 9
#define COL_ETA_W 6

#define COL_FOLDER_X (COL_TITLE_X + COL_TITLE_W + 1)
#define COL_PROGRESS_X (COL_FOLDER_X + COL_FOLDER_W + 1)
#define COL_SPEED_X (COL_PROGRESS_X + COL_PROGRESS_W + 1)
#define COL_ETA_X (COL_SPEED_X + COL_SPEED_W + 1)

#define URL_WIN_H 3
#define SETTINGS_WIN_H 4
#define MESSAGE_H 1

void ui_init(UiState *ui) {
    memset(ui, 0, sizeof(*ui));
    input_field_init(&ui->url_field);
    ui->focus = FOCUS_URL;
    ui->selected_row = 0;
    ui->quit_requested = 0;
    colors_init();
}

void ui_destroy(UiState *ui) {
    if (ui->url_win) delwin(ui->url_win);
    if (ui->settings_win) delwin(ui->settings_win);
    if (ui->table_win) delwin(ui->table_win);
    ui->url_win = ui->settings_win = ui->table_win = NULL;
}

static const char *status_icon(ItemStatus s) {
    switch (s) {
        case STATUS_PENDING: return ICON_PENDING;
        case STATUS_DOWNLOADING: return ICON_DOWNLOADING;
        case STATUS_DONE: return ICON_DONE;
        case STATUS_ERROR: return ICON_ERROR;
        default: return "?";
    }
}

static int status_pair(ItemStatus s) {
    switch (s) {
        case STATUS_PENDING: return PAIR_STATUS_PENDING;
        case STATUS_DOWNLOADING: return PAIR_STATUS_DOWNLOADING;
        case STATUS_DONE: return PAIR_STATUS_DONE;
        case STATUS_ERROR: return PAIR_STATUS_ERROR;
        default: return 0;
    }
}

static void box_title(WINDOW *win, const char *title) {
    wattron(win, A_BOLD);
    mvwprintw(win, 0, 2, " %s ", title);
    wattroff(win, A_BOLD);
}

static void ensure_layout(UiState *ui, int rows, int cols) {
    if (ui->layout_rows == rows && ui->layout_cols == cols && ui->url_win) return;

    if (ui->url_win) delwin(ui->url_win);
    if (ui->settings_win) delwin(ui->settings_win);
    if (ui->table_win) delwin(ui->table_win);

    int x = 1;
    int w = cols - 2;
    if (w < 10) w = 10;

    int y = 1;
    ui->url_win = newwin(URL_WIN_H, w, y, x);
    y += URL_WIN_H;

    ui->settings_win = newwin(SETTINGS_WIN_H, w, y, x);
    y += SETTINGS_WIN_H;

    y += MESSAGE_H; /* leave a blank/message row between settings and table */

    int table_h = rows - y - 1; /* leave the last row for footer hints */
    if (table_h < 4) table_h = 4;
    ui->table_win = newwin(table_h, w, y, x);

    ui->layout_rows = rows;
    ui->layout_cols = cols;
}

static void draw_progress_cell(WINDOW *win, int y, int x, const QueueItem *it) {
    if (it->status == STATUS_ERROR) {
        wattron(win, COLOR_PAIR(PAIR_STATUS_ERROR));
        mvwprintw(win, y, x, "%-*.*s", COL_PROGRESS_W, COL_PROGRESS_W, it->error);
        wattroff(win, COLOR_PAIR(PAIR_STATUS_ERROR));
        return;
    }

    float percent = it->percent;
    if (percent < 0) percent = 0;
    if (percent > 100) percent = 100;
    int filled = (int)(COL_PROGRESS_BAR_W * percent / 100.0f + 0.5f);
    if (filled > COL_PROGRESS_BAR_W) filled = COL_PROGRESS_BAR_W;

    wattron(win, COLOR_PAIR(PAIR_PROGRESS_FILLED));
    for (int i = 0; i < filled; i++) mvwaddstr(win, y, x + i, BLOCK_FULL);
    wattroff(win, COLOR_PAIR(PAIR_PROGRESS_FILLED));
    wattron(win, A_DIM);
    for (int i = filled; i < COL_PROGRESS_BAR_W; i++) mvwaddstr(win, y, x + i, BLOCK_LIGHT);
    wattroff(win, A_DIM);

    mvwprintw(win, y, x + COL_PROGRESS_BAR_W + 1, "%3.0f%%", percent);
}

void ui_draw(App *app, UiState *ui) {
    int rows, cols;
    getmaxyx(stdscr, rows, cols);
    ensure_layout(ui, rows, cols);
    erase();

    attron(A_BOLD | COLOR_PAIR(PAIR_TITLE));
    mvprintw(0, 2, "ytmdl - YouTube Music Downloader");
    attroff(A_BOLD | COLOR_PAIR(PAIR_TITLE));

    /* --- Message line ------------------------------------------------------- */
    int msg_y = 1 + URL_WIN_H + SETTINGS_WIN_H;
    char msg[MESSAGE_CAP];
    int is_err = 0;
    app_get_message(app, msg, sizeof(msg), &is_err);
    if (msg[0]) {
        int pair = is_err ? PAIR_MSG_ERROR : PAIR_MSG_INFO;
        attron(A_BOLD | COLOR_PAIR(pair));
        mvprintw(msg_y, 3, "%.*s", cols - 5, msg);
        attroff(A_BOLD | COLOR_PAIR(pair));
    }

    attron(A_DIM);
    mvprintw(rows - 1, 2,
             "Tab: switch focus  Up/Down: select  d/Del: remove  r: retry  "
             "Ctrl+N: clear finished  Ctrl+Q: quit");
    attroff(A_DIM);

    /* stdscr and the panel sub-windows all cover overlapping screen regions;
     * whichever one calls wnoutrefresh() last "wins" those cells in
     * ncurses' shared virtual screen. stdscr must go first so the panels
     * (refreshed below) draw on top of it, not the other way around. */
    wnoutrefresh(stdscr);

    /* --- URL box -------------------------------------------------------- */
    WINDOW *uw = ui->url_win;
    werase(uw);
    draw_rounded_box(uw, ui->focus == FOCUS_URL ? PAIR_BORDER_FOCUS : PAIR_BORDER);
    box_title(uw, "URL");
    int url_w = getmaxx(uw) - 4;
    input_field_draw(uw, 1, 2, url_w, &ui->url_field, ui->focus == FOCUS_URL,
                      "Paste a video, playlist, or album URL and press Enter");
    int cursor_y = 0, cursor_x = -1;
    if (ui->focus == FOCUS_URL) getyx(uw, cursor_y, cursor_x);
    wnoutrefresh(uw);

    /* --- Settings box ----------------------------------------------------- */
    WINDOW *sw = ui->settings_win;
    werase(sw);
    draw_rounded_box(sw, PAIR_BORDER);
    box_title(sw, "Settings");

    char output_dir[OUTPUT_DIR_CAP];
    app_get_output_dir(app, output_dir, sizeof(output_dir));
    int sw_w = getmaxx(sw);
    static const char *saving_to_prefix = "Saving to: ";
    static const char *saving_to_hint = "  (Ctrl+O to change)";
    int hint_len = (int)strlen(saving_to_hint);
    int path_dir_len = (int)strlen(output_dir);
    int avail = sw_w - 4 - (int)strlen(saving_to_prefix);
    int show_hint = avail - path_dir_len >= hint_len;
    int path_w = show_hint ? path_dir_len : avail;
    if (path_w < 0) path_w = 0;
    mvwprintw(sw, 1, 2, "%s%.*s", saving_to_prefix, path_w, output_dir);
    int end_x = 2 + (int)strlen(saving_to_prefix) + path_w;
    if (show_hint) {
        wattron(sw, A_DIM);
        mvwprintw(sw, 1, end_x, "%s", saving_to_hint);
        wattroff(sw, A_DIM);
    }

    int pf = app_get_playlist_folders(app);
    mvwprintw(sw, 2, 2, "Playlist folders: %s", pf ? "On" : "Off");
    wattron(sw, A_DIM);
    mvwprintw(sw, 2, 2 + 18 + (pf ? 3 : 4), "  (Ctrl+F to toggle)");
    wattroff(sw, A_DIM);
    wnoutrefresh(sw);

    /* --- Queue table box ------------------------------------------------------ */
    WINDOW *tw = ui->table_win;
    werase(tw);
    draw_rounded_box(tw, ui->focus == FOCUS_TABLE ? PAIR_BORDER_FOCUS : PAIR_BORDER);
    box_title(tw, "Queue");

    int tw_w = getmaxx(tw);
    int tw_h = getmaxy(tw);

    attron(A_UNDERLINE);
    mvwprintw(tw, 1, COL_ICON_X, "St");
    mvwprintw(tw, 1, COL_TITLE_X, "%-*.*s", COL_TITLE_W, COL_TITLE_W, "Title");
    if (COL_FOLDER_X + COL_FOLDER_W < tw_w)
        mvwprintw(tw, 1, COL_FOLDER_X, "%-*.*s", COL_FOLDER_W, COL_FOLDER_W, "Folder");
    if (COL_PROGRESS_X + COL_PROGRESS_W < tw_w)
        mvwprintw(tw, 1, COL_PROGRESS_X, "%-*.*s", COL_PROGRESS_W, COL_PROGRESS_W, "Progress");
    if (COL_SPEED_X + COL_SPEED_W < tw_w)
        mvwprintw(tw, 1, COL_SPEED_X, "%-*.*s", COL_SPEED_W, COL_SPEED_W, "Speed");
    if (COL_ETA_X + COL_ETA_W < tw_w)
        mvwprintw(tw, 1, COL_ETA_X, "%-*.*s", COL_ETA_W, COL_ETA_W, "ETA");
    wattroff(tw, A_UNDERLINE);

    pthread_mutex_lock(&app->queue.lock);
    int count = app->queue.count;
    if (ui->selected_row >= count) ui->selected_row = count > 0 ? count - 1 : 0;
    if (ui->selected_row < 0) ui->selected_row = 0;

    int table_h = tw_h - 3; /* border top, header row, border bottom */
    if (table_h < 0) table_h = 0;
    int start = 0;
    if (ui->selected_row >= table_h) start = ui->selected_row - table_h + 1;

    for (int row = 0; row < table_h && start + row < count; row++) {
        int idx = start + row;
        QueueItem *it = &app->queue.items[idx];
        int ry = row + 2;
        int hl = (ui->focus == FOCUS_TABLE && idx == ui->selected_row);
        if (hl) wattron(tw, A_REVERSE);

        int pair = status_pair(it->status);
        if (pair && !hl) wattron(tw, COLOR_PAIR(pair));
        mvwprintw(tw, ry, COL_ICON_X, "%s", status_icon(it->status));
        if (pair && !hl) wattroff(tw, COLOR_PAIR(pair));

        mvwprintw(tw, ry, COL_TITLE_X, "%-*.*s", COL_TITLE_W, COL_TITLE_W, it->title);
        if (COL_FOLDER_X + COL_FOLDER_W < tw_w)
            mvwprintw(tw, ry, COL_FOLDER_X, "%-*.*s", COL_FOLDER_W, COL_FOLDER_W, it->subdir);
        if (COL_PROGRESS_X + COL_PROGRESS_W < tw_w && !hl)
            draw_progress_cell(tw, ry, COL_PROGRESS_X, it);
        else if (COL_PROGRESS_X + COL_PROGRESS_W < tw_w)
            mvwprintw(tw, ry, COL_PROGRESS_X, "%*.0f%%", COL_PROGRESS_W - 1, it->percent);
        if (COL_SPEED_X + COL_SPEED_W < tw_w)
            mvwprintw(tw, ry, COL_SPEED_X, "%-*.*s", COL_SPEED_W, COL_SPEED_W, it->speed);
        if (COL_ETA_X + COL_ETA_W < tw_w)
            mvwprintw(tw, ry, COL_ETA_X, "%-*.*s", COL_ETA_W, COL_ETA_W, it->eta);

        if (hl) wattroff(tw, A_REVERSE);
    }
    pthread_mutex_unlock(&app->queue.lock);
    wnoutrefresh(tw);

    if (ui->focus == FOCUS_URL && cursor_x >= 0) {
        curs_set(1);
        wmove(uw, cursor_y, cursor_x);
        wnoutrefresh(uw);
    } else {
        curs_set(0);
    }

    doupdate();
}

static int selected_item_id(App *app, UiState *ui) {
    pthread_mutex_lock(&app->queue.lock);
    int id = -1;
    if (ui->selected_row >= 0 && ui->selected_row < app->queue.count) {
        id = app->queue.items[ui->selected_row].id;
    }
    pthread_mutex_unlock(&app->queue.lock);
    return id;
}

void ui_handle_key(App *app, UiState *ui, int ch) {
    /* Global shortcuts, always available regardless of focus. */
    switch (ch) {
        case 15: { /* Ctrl+O */
            char output_dir[OUTPUT_DIR_CAP];
            app_get_output_dir(app, output_dir, sizeof(output_dir));
            char chosen[OUTPUT_DIR_CAP];
            if (dirpicker_run(output_dir, chosen, sizeof(chosen))) {
                app_set_output_dir(app, chosen);
            }
            return;
        }
        case 14: /* Ctrl+N */
            app_clear_finished(app);
            return;
        case 6: /* Ctrl+F */
            app_set_playlist_folders(app, !app_get_playlist_folders(app));
            return;
        case 17: /* Ctrl+Q */
            ui->quit_requested = 1;
            return;
        case '\t':
            ui->focus = (ui->focus == FOCUS_URL) ? FOCUS_TABLE : FOCUS_URL;
            return;
        default:
            break;
    }

    if (ui->focus == FOCUS_URL) {
        if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) {
            if (ui->url_field.len > 0) {
                app_submit_url(app, ui->url_field.buf);
                input_field_clear(&ui->url_field);
            }
            return;
        }
        input_field_handle_key(&ui->url_field, ch);
        return;
    }

    /* FOCUS_TABLE */
    switch (ch) {
        case KEY_UP:
            if (ui->selected_row > 0) ui->selected_row--;
            break;
        case KEY_DOWN:
            ui->selected_row++;
            break;
        case 'd':
        case KEY_DC:
        case 127:
        case 8: {
            int id = selected_item_id(app, ui);
            if (id >= 0) app_remove_item(app, id);
            break;
        }
        case 'r': {
            int id = selected_item_id(app, ui);
            if (id >= 0) app_retry_item(app, id);
            break;
        }
        default:
            break;
    }
}