commits
tags
#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;
}
}