2026-03-27 21:45:40 -04:00
|
|
|
#include "ui.h"
|
|
|
|
|
|
|
|
|
|
#include <stdio.h>
|
|
|
|
|
#include <string.h>
|
|
|
|
|
|
|
|
|
|
static const char *FONT_CANDIDATES[] = {
|
|
|
|
|
"BigBlueTermPlus Nerd Font Mono.ttf",
|
|
|
|
|
"BigBlueTermPlusNerdFontMono-Regular.ttf",
|
|
|
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
|
|
|
|
"/usr/share/fonts/TTF/DejaVuSansMono.ttf",
|
|
|
|
|
"/usr/share/fonts/truetype/liberation2/LiberationMono-Regular.ttf"
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static void fill_rect(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color color);
|
2026-03-27 23:19:42 -04:00
|
|
|
static SDL_Texture *text_to_texture(SDL_Renderer *renderer, TTF_Font *font, const char *text, SDL_Color color, int *width, int *height);
|
|
|
|
|
|
|
|
|
|
static void text_texture_destroy(UiTextTexture *text_texture) {
|
|
|
|
|
if (!text_texture) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (text_texture->texture) {
|
|
|
|
|
SDL_DestroyTexture(text_texture->texture);
|
|
|
|
|
}
|
|
|
|
|
memset(text_texture, 0, sizeof(*text_texture));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static int text_texture_init(UiTextTexture *text_texture,
|
|
|
|
|
SDL_Renderer *renderer,
|
|
|
|
|
TTF_Font *font,
|
|
|
|
|
const char *text,
|
|
|
|
|
SDL_Color color) {
|
|
|
|
|
if (!text_texture) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
memset(text_texture, 0, sizeof(*text_texture));
|
|
|
|
|
text_texture->texture = text_to_texture(renderer, font, text, color, &text_texture->width, &text_texture->height);
|
|
|
|
|
return text_texture->texture ? 0 : -1;
|
|
|
|
|
}
|
2026-03-27 21:45:40 -04:00
|
|
|
|
|
|
|
|
static void set_draw_color(SDL_Renderer *renderer, SDL_Color color) {
|
|
|
|
|
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 01:40:11 -04:00
|
|
|
static SDL_Color color_with_alpha(SDL_Color color, Uint8 alpha) {
|
|
|
|
|
color.a = alpha;
|
|
|
|
|
return color;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static SDL_Color blend_color(SDL_Color from, SDL_Color to, int weight_to_255) {
|
|
|
|
|
SDL_Color blended;
|
|
|
|
|
int weight_from = 255 - weight_to_255;
|
|
|
|
|
|
|
|
|
|
blended.r = (Uint8) ((from.r * weight_from + to.r * weight_to_255) / 255);
|
|
|
|
|
blended.g = (Uint8) ((from.g * weight_from + to.g * weight_to_255) / 255);
|
|
|
|
|
blended.b = (Uint8) ((from.b * weight_from + to.b * weight_to_255) / 255);
|
|
|
|
|
blended.a = 255;
|
|
|
|
|
return blended;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static int color_luma(SDL_Color color) {
|
|
|
|
|
return (299 * color.r + 587 * color.g + 114 * color.b) / 1000;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static SDL_Color readable_text_color(SDL_Color background) {
|
|
|
|
|
return color_luma(background) < 120 ? COLOR_TEXT_LIGHT : COLOR_TEXT_DARK;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static SDL_Color secondary_text_color(SDL_Color primary, SDL_Color background) {
|
|
|
|
|
SDL_Color secondary = blend_color(primary, background, 110);
|
|
|
|
|
secondary.a = 255;
|
|
|
|
|
return secondary;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 21:45:40 -04:00
|
|
|
static SDL_Texture *text_to_texture(SDL_Renderer *renderer, TTF_Font *font, const char *text, SDL_Color color, int *width, int *height) {
|
|
|
|
|
SDL_Surface *surface;
|
|
|
|
|
SDL_Texture *texture;
|
|
|
|
|
|
|
|
|
|
if (!font || !text || text[0] == '\0') {
|
|
|
|
|
return NULL;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 00:08:52 -04:00
|
|
|
surface = TTF_RenderUTF8_Solid(font, text, color);
|
2026-03-27 21:45:40 -04:00
|
|
|
if (!surface) {
|
|
|
|
|
return NULL;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
texture = SDL_CreateTextureFromSurface(renderer, surface);
|
|
|
|
|
if (width) {
|
|
|
|
|
*width = surface->w;
|
|
|
|
|
}
|
|
|
|
|
if (height) {
|
|
|
|
|
*height = surface->h;
|
|
|
|
|
}
|
|
|
|
|
SDL_FreeSurface(surface);
|
|
|
|
|
return texture;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 23:19:42 -04:00
|
|
|
static void draw_cached_text(SDL_Renderer *renderer, const UiTextTexture *text_texture, int x, int y) {
|
2026-03-27 21:45:40 -04:00
|
|
|
SDL_Rect dst;
|
|
|
|
|
|
2026-03-27 23:19:42 -04:00
|
|
|
if (!text_texture || !text_texture->texture) {
|
2026-03-27 21:45:40 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dst.x = x;
|
|
|
|
|
dst.y = y;
|
2026-03-27 23:19:42 -04:00
|
|
|
dst.w = text_texture->width;
|
|
|
|
|
dst.h = text_texture->height;
|
|
|
|
|
SDL_RenderCopy(renderer, text_texture->texture, NULL, &dst);
|
2026-03-27 21:45:40 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-28 00:08:52 -04:00
|
|
|
static void fill_three_stop_gradient(SDL_Renderer *renderer,
|
|
|
|
|
const SDL_Rect *rect,
|
|
|
|
|
SDL_Color top,
|
|
|
|
|
SDL_Color middle,
|
|
|
|
|
SDL_Color bottom) {
|
|
|
|
|
int split;
|
|
|
|
|
|
2026-03-27 23:33:09 -04:00
|
|
|
if (!rect || rect->h <= 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 01:40:11 -04:00
|
|
|
top = blend_color(top, middle, 140);
|
|
|
|
|
bottom = blend_color(bottom, middle, 140);
|
|
|
|
|
|
2026-03-28 00:08:52 -04:00
|
|
|
split = rect->h / 2;
|
2026-03-27 23:33:09 -04:00
|
|
|
for (int i = 0; i < rect->h; ++i) {
|
2026-03-28 00:08:52 -04:00
|
|
|
SDL_Color from = i < split ? top : middle;
|
|
|
|
|
SDL_Color to = i < split ? middle : bottom;
|
|
|
|
|
int segment_height = i < split ? SDL_max(split, 1) : SDL_max(rect->h - split, 1);
|
|
|
|
|
int segment_pos = i < split ? i : i - split;
|
|
|
|
|
Uint8 r = (Uint8) (from.r + (to.r - from.r) * segment_pos / SDL_max(segment_height - 1, 1));
|
|
|
|
|
Uint8 g = (Uint8) (from.g + (to.g - from.g) * segment_pos / SDL_max(segment_height - 1, 1));
|
|
|
|
|
Uint8 b = (Uint8) (from.b + (to.b - from.b) * segment_pos / SDL_max(segment_height - 1, 1));
|
2026-03-27 23:33:09 -04:00
|
|
|
SDL_SetRenderDrawColor(renderer, r, g, b, 255);
|
|
|
|
|
SDL_RenderDrawLine(renderer, rect->x, rect->y + i, rect->x + rect->w, rect->y + i);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void fill_rect_alpha(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color color) {
|
|
|
|
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
|
|
|
|
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a);
|
|
|
|
|
SDL_RenderFillRect(renderer, rect);
|
|
|
|
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void draw_panel_shadow(SDL_Renderer *renderer, const SDL_Rect *rect) {
|
|
|
|
|
SDL_Rect shadow = {rect->x + 6, rect->y + 6, rect->w, rect->h};
|
2026-03-28 01:40:11 -04:00
|
|
|
fill_rect_alpha(renderer, &shadow, color_with_alpha(COLOR_PANEL_SHADOW, 48));
|
2026-03-27 23:33:09 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-28 01:40:11 -04:00
|
|
|
static void draw_gloss_line(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color gloss_color) {
|
2026-03-28 00:08:52 -04:00
|
|
|
SDL_Rect gloss = {rect->x + 4, rect->y + 1, SDL_max(rect->w - 8, 0), 2};
|
|
|
|
|
if (gloss.w > 0) {
|
2026-03-28 01:40:11 -04:00
|
|
|
fill_rect_alpha(renderer, &gloss, gloss_color);
|
2026-03-28 00:08:52 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 01:40:11 -04:00
|
|
|
static void draw_panel_bevel(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color gloss_color) {
|
2026-03-28 00:08:52 -04:00
|
|
|
SDL_Rect top_white = {rect->x + 8, rect->y, SDL_max(rect->w - 16, 0), 1};
|
|
|
|
|
SDL_Rect top_black = {rect->x + 8, rect->y + 1, SDL_max(rect->w - 16, 0), 1};
|
|
|
|
|
|
|
|
|
|
if (top_white.w > 0) {
|
2026-03-28 01:40:11 -04:00
|
|
|
fill_rect_alpha(renderer, &top_white, color_with_alpha(gloss_color, 72));
|
|
|
|
|
fill_rect_alpha(renderer, &top_black, (SDL_Color){0, 0, 0, 28});
|
2026-03-28 00:08:52 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void stroke_rect_alpha(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color color) {
|
|
|
|
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
|
|
|
|
SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a);
|
|
|
|
|
SDL_RenderDrawRect(renderer, rect);
|
|
|
|
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 01:40:11 -04:00
|
|
|
static void draw_selection_glow(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color edge) {
|
2026-03-28 00:08:52 -04:00
|
|
|
SDL_Rect outer = *rect;
|
|
|
|
|
SDL_Rect inner = {rect->x + 1, rect->y + 1, rect->w - 2, rect->h - 2};
|
2026-03-28 01:40:11 -04:00
|
|
|
stroke_rect_alpha(renderer, &outer, color_with_alpha(edge, 62));
|
2026-03-28 00:08:52 -04:00
|
|
|
if (inner.w > 0 && inner.h > 0) {
|
2026-03-28 01:40:11 -04:00
|
|
|
stroke_rect_alpha(renderer, &inner, color_with_alpha(edge, 34));
|
2026-03-28 00:08:52 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 01:40:11 -04:00
|
|
|
static void draw_rounded_top_panel(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color fill_color, SDL_Color border_color, int radius) {
|
2026-03-28 00:08:52 -04:00
|
|
|
SDL_Rect body;
|
|
|
|
|
SDL_Rect top;
|
|
|
|
|
|
2026-03-28 01:40:11 -04:00
|
|
|
if (!rect || rect->w < radius || rect->h < radius) {
|
2026-03-28 00:08:52 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 01:40:11 -04:00
|
|
|
body = (SDL_Rect){rect->x, rect->y + radius, rect->w, rect->h - radius};
|
|
|
|
|
top = (SDL_Rect){rect->x + radius, rect->y, rect->w - radius * 2, radius};
|
2026-03-28 00:08:52 -04:00
|
|
|
fill_rect(renderer, &body, fill_color);
|
|
|
|
|
fill_rect(renderer, &top, fill_color);
|
2026-03-28 01:40:11 -04:00
|
|
|
fill_rect(renderer, &(SDL_Rect){rect->x + radius / 2, rect->y + 2, rect->w - radius, radius - 2}, fill_color);
|
|
|
|
|
fill_rect(renderer, &(SDL_Rect){rect->x + 2, rect->y + radius / 2, rect->w - 4, radius / 2}, fill_color);
|
|
|
|
|
|
|
|
|
|
set_draw_color(renderer, border_color);
|
|
|
|
|
SDL_RenderDrawLine(renderer, rect->x + radius, rect->y, rect->x + rect->w - radius - 1, rect->y);
|
|
|
|
|
SDL_RenderDrawLine(renderer, rect->x + radius / 2, rect->y + 2, rect->x + radius / 2 + 1, rect->y + 1);
|
|
|
|
|
SDL_RenderDrawLine(renderer, rect->x + rect->w - radius / 2 - 2, rect->y + 1, rect->x + rect->w - radius / 2 - 1, rect->y + 2);
|
|
|
|
|
SDL_RenderDrawLine(renderer, rect->x + 1, rect->y + radius - 2, rect->x + 1, rect->y + rect->h - 2);
|
|
|
|
|
SDL_RenderDrawLine(renderer, rect->x + rect->w - 2, rect->y + radius - 2, rect->x + rect->w - 2, rect->y + rect->h - 2);
|
2026-03-28 00:08:52 -04:00
|
|
|
SDL_RenderDrawLine(renderer, rect->x + 1, rect->y + rect->h - 1, rect->x + rect->w - 2, rect->y + rect->h - 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void draw_beveled_bar(SDL_Renderer *renderer,
|
|
|
|
|
const SDL_Rect *rect,
|
|
|
|
|
SDL_Color top,
|
|
|
|
|
SDL_Color mid,
|
|
|
|
|
SDL_Color bottom,
|
|
|
|
|
SDL_Color top_edge,
|
|
|
|
|
SDL_Color bottom_edge) {
|
|
|
|
|
if (!rect) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fill_three_stop_gradient(renderer, rect, top, mid, bottom);
|
2026-03-28 01:40:11 -04:00
|
|
|
top_edge = blend_color(top_edge, mid, 170);
|
|
|
|
|
bottom_edge = blend_color(bottom_edge, mid, 170);
|
|
|
|
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
|
|
|
|
|
SDL_SetRenderDrawColor(renderer, top_edge.r, top_edge.g, top_edge.b, 110);
|
2026-03-28 00:08:52 -04:00
|
|
|
SDL_RenderDrawLine(renderer, rect->x, rect->y, rect->x + rect->w - 1, rect->y);
|
2026-03-28 01:40:11 -04:00
|
|
|
SDL_SetRenderDrawColor(renderer, bottom_edge.r, bottom_edge.g, bottom_edge.b, 110);
|
2026-03-28 00:08:52 -04:00
|
|
|
SDL_RenderDrawLine(renderer, rect->x, rect->y + rect->h - 1, rect->x + rect->w - 1, rect->y + rect->h - 1);
|
2026-03-28 01:40:11 -04:00
|
|
|
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_NONE);
|
2026-03-28 00:08:52 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-28 01:40:11 -04:00
|
|
|
static void draw_pill_button(SDL_Renderer *renderer, const GuideTheme *theme, const SDL_Rect *rect, SDL_Color fill, SDL_Color border) {
|
2026-03-28 00:08:52 -04:00
|
|
|
SDL_Rect shadow;
|
|
|
|
|
|
|
|
|
|
if (!rect || rect->w < 10 || rect->h < 10) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
shadow = (SDL_Rect){rect->x + 2, rect->y + 2, rect->w, rect->h};
|
2026-03-28 01:40:11 -04:00
|
|
|
fill_rect_alpha(renderer, &shadow, theme->pill_shadow);
|
|
|
|
|
draw_beveled_bar(renderer, rect, theme->pill_light, fill, fill, theme->gloss, border);
|
2026-03-28 00:08:52 -04:00
|
|
|
set_draw_color(renderer, border);
|
|
|
|
|
SDL_RenderDrawLine(renderer, rect->x + 4, rect->y, rect->x + rect->w - 5, rect->y);
|
|
|
|
|
SDL_RenderDrawLine(renderer, rect->x + 4, rect->y + rect->h - 1, rect->x + rect->w - 5, rect->y + rect->h - 1);
|
|
|
|
|
SDL_RenderDrawLine(renderer, rect->x, rect->y + 4, rect->x, rect->y + rect->h - 5);
|
|
|
|
|
SDL_RenderDrawLine(renderer, rect->x + rect->w - 1, rect->y + 4, rect->x + rect->w - 1, rect->y + rect->h - 5);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 23:33:09 -04:00
|
|
|
static void draw_text_clipped(SDL_Renderer *renderer,
|
|
|
|
|
TTF_Font *font,
|
|
|
|
|
const char *text,
|
|
|
|
|
const SDL_Rect *clip_rect,
|
|
|
|
|
int x,
|
|
|
|
|
int y,
|
|
|
|
|
SDL_Color color) {
|
|
|
|
|
SDL_Rect previous_clip;
|
|
|
|
|
SDL_bool had_clip = SDL_RenderIsClipEnabled(renderer);
|
|
|
|
|
if (had_clip) {
|
|
|
|
|
SDL_RenderGetClipRect(renderer, &previous_clip);
|
|
|
|
|
}
|
|
|
|
|
SDL_RenderSetClipRect(renderer, clip_rect);
|
|
|
|
|
{
|
|
|
|
|
SDL_Texture *texture;
|
|
|
|
|
int width = 0;
|
|
|
|
|
int height = 0;
|
|
|
|
|
SDL_Rect dst;
|
|
|
|
|
|
|
|
|
|
texture = text_to_texture(renderer, font, text, color, &width, &height);
|
|
|
|
|
if (texture) {
|
|
|
|
|
dst.x = x;
|
|
|
|
|
dst.y = y;
|
|
|
|
|
dst.w = width;
|
|
|
|
|
dst.h = height;
|
|
|
|
|
SDL_RenderCopy(renderer, texture, NULL, &dst);
|
|
|
|
|
SDL_DestroyTexture(texture);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
SDL_RenderSetClipRect(renderer, had_clip ? &previous_clip : NULL);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void fit_text_with_ellipsis(TTF_Font *font, const char *text, int max_width, char *out, size_t out_size) {
|
|
|
|
|
int width = 0;
|
|
|
|
|
size_t len;
|
|
|
|
|
|
|
|
|
|
if (!out || out_size == 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
out[0] = '\0';
|
|
|
|
|
if (!font || !text || max_width <= 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (TTF_SizeUTF8(font, text, &width, NULL) == 0 && width <= max_width) {
|
|
|
|
|
snprintf(out, out_size, "%s", text);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
len = strlen(text);
|
|
|
|
|
while (len > 0) {
|
|
|
|
|
snprintf(out, out_size, "%.*s...", (int) len, text);
|
|
|
|
|
if (TTF_SizeUTF8(font, out, &width, NULL) == 0 && width <= max_width) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
len -= 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
snprintf(out, out_size, "...");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void format_time_compact(char *buffer, size_t buffer_size, time_t value) {
|
|
|
|
|
struct tm local_time;
|
|
|
|
|
localtime_r(&value, &local_time);
|
|
|
|
|
strftime(buffer, buffer_size, "%I:%M %p", &local_time);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 21:45:40 -04:00
|
|
|
static void fill_rect(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color color) {
|
|
|
|
|
set_draw_color(renderer, color);
|
|
|
|
|
SDL_RenderFillRect(renderer, rect);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void stroke_rect(SDL_Renderer *renderer, const SDL_Rect *rect, SDL_Color color) {
|
|
|
|
|
set_draw_color(renderer, color);
|
|
|
|
|
SDL_RenderDrawRect(renderer, rect);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static SDL_Rect fit_rect(int outer_x, int outer_y, int outer_w, int outer_h, int inner_w, int inner_h) {
|
|
|
|
|
SDL_Rect dst = {outer_x, outer_y, outer_w, outer_h};
|
|
|
|
|
double scale;
|
|
|
|
|
|
|
|
|
|
if (inner_w <= 0 || inner_h <= 0) {
|
|
|
|
|
return dst;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scale = SDL_min((double) outer_w / (double) inner_w, (double) outer_h / (double) inner_h);
|
|
|
|
|
dst.w = (int) (inner_w * scale);
|
|
|
|
|
dst.h = (int) (inner_h * scale);
|
|
|
|
|
dst.x = outer_x + (outer_w - dst.w) / 2;
|
|
|
|
|
dst.y = outer_y + (outer_h - dst.h) / 2;
|
|
|
|
|
return dst;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void draw_video(SDL_Renderer *renderer, SDL_Texture *video_texture, int texture_width, int texture_height, SDL_Rect destination) {
|
|
|
|
|
if (!video_texture) {
|
|
|
|
|
fill_rect(renderer, &destination, COLOR_BLACK);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
SDL_Rect fitted = fit_rect(destination.x, destination.y, destination.w, destination.h, texture_width, texture_height);
|
|
|
|
|
fill_rect(renderer, &destination, COLOR_BLACK);
|
|
|
|
|
SDL_RenderCopy(renderer, video_texture, NULL, &fitted);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void format_clock_label(char *buffer, size_t buffer_size, time_t now, int minute_offset) {
|
|
|
|
|
struct tm local_time;
|
|
|
|
|
time_t slot = now + minute_offset * 60;
|
|
|
|
|
localtime_r(&slot, &local_time);
|
|
|
|
|
strftime(buffer, buffer_size, "%I:%M %p", &local_time);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 01:40:11 -04:00
|
|
|
static void draw_timeline_header_cached(SDL_Renderer *renderer, const UiCache *cache, const GuideTheme *theme, SDL_Rect rect) {
|
2026-03-27 21:45:40 -04:00
|
|
|
int segments = 4;
|
|
|
|
|
|
2026-03-28 00:08:52 -04:00
|
|
|
draw_beveled_bar(renderer,
|
|
|
|
|
&rect,
|
2026-03-28 01:40:11 -04:00
|
|
|
theme->ribbon_top,
|
|
|
|
|
theme->ribbon_mid,
|
|
|
|
|
theme->ribbon_bottom,
|
|
|
|
|
theme->gloss,
|
|
|
|
|
theme->panel_border);
|
2026-03-28 00:08:52 -04:00
|
|
|
|
2026-03-27 21:45:40 -04:00
|
|
|
for (int i = 0; i < segments; ++i) {
|
|
|
|
|
int x = rect.x + (rect.w * i) / segments;
|
2026-03-28 00:08:52 -04:00
|
|
|
int centered_x = x - cache->timeline_labels[i].width / 2;
|
|
|
|
|
draw_cached_text(renderer, &cache->timeline_labels[i], centered_x, rect.y + 10);
|
2026-03-28 01:40:11 -04:00
|
|
|
set_draw_color(renderer, theme->grid_line);
|
2026-03-27 23:33:09 -04:00
|
|
|
SDL_RenderDrawLine(renderer, x, rect.y + rect.h - 2, x, rect.y + rect.h + 5 * 76);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void draw_status_bar(SDL_Renderer *renderer,
|
|
|
|
|
TTF_Font *font,
|
2026-03-28 01:40:11 -04:00
|
|
|
const GuideTheme *theme,
|
2026-03-27 23:33:09 -04:00
|
|
|
const Channel *selected_channel,
|
|
|
|
|
const SDL_Rect *rect,
|
|
|
|
|
time_t now_wall) {
|
|
|
|
|
char clock_text[32];
|
|
|
|
|
char channel_text[160];
|
2026-03-28 01:40:11 -04:00
|
|
|
SDL_Color text_color;
|
2026-03-27 23:33:09 -04:00
|
|
|
|
|
|
|
|
if (!rect) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 01:40:11 -04:00
|
|
|
text_color = readable_text_color(theme->status_mid);
|
|
|
|
|
|
2026-03-28 00:08:52 -04:00
|
|
|
draw_beveled_bar(renderer,
|
|
|
|
|
rect,
|
2026-03-28 01:40:11 -04:00
|
|
|
blend_color(theme->status_top, theme->status_mid, 220),
|
|
|
|
|
theme->status_mid,
|
|
|
|
|
blend_color(theme->status_bottom, theme->status_mid, 220),
|
|
|
|
|
color_with_alpha(theme->gloss, 36),
|
|
|
|
|
theme->panel_border);
|
2026-03-27 23:33:09 -04:00
|
|
|
format_time_compact(clock_text, sizeof(clock_text), now_wall);
|
2026-03-28 01:40:11 -04:00
|
|
|
draw_text_clipped(renderer, font, clock_text, rect, rect->x + 12, rect->y + 10, text_color);
|
|
|
|
|
set_draw_color(renderer, theme->status_divider);
|
2026-03-27 23:33:09 -04:00
|
|
|
SDL_RenderDrawLine(renderer, rect->x + rect->w / 2, rect->y + 8, rect->x + rect->w / 2, rect->y + rect->h - 8);
|
|
|
|
|
|
|
|
|
|
if (selected_channel) {
|
|
|
|
|
snprintf(channel_text, sizeof(channel_text), "%s %d", selected_channel->name, selected_channel->number);
|
2026-03-28 01:40:11 -04:00
|
|
|
draw_text_clipped(renderer, font, channel_text, rect, rect->x + rect->w - 260, rect->y + 10, text_color);
|
2026-03-27 23:33:09 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void draw_info_panel(SDL_Renderer *renderer,
|
|
|
|
|
const UiFonts *fonts,
|
2026-03-28 01:40:11 -04:00
|
|
|
const GuideTheme *theme,
|
2026-03-27 23:33:09 -04:00
|
|
|
const Channel *selected_channel,
|
|
|
|
|
const SDL_Rect *rect,
|
|
|
|
|
Uint64 app_start_ticks,
|
|
|
|
|
Uint64 now_ticks,
|
|
|
|
|
time_t now_wall) {
|
|
|
|
|
SDL_Rect accent;
|
|
|
|
|
SDL_Rect clip_rect;
|
|
|
|
|
char time_range[64];
|
|
|
|
|
char end_text[32];
|
|
|
|
|
char description[384];
|
|
|
|
|
time_t start_time;
|
|
|
|
|
time_t end_time;
|
2026-03-28 00:25:06 -04:00
|
|
|
double program_seek;
|
|
|
|
|
const ProgramEntry *program;
|
2026-03-27 23:33:09 -04:00
|
|
|
|
|
|
|
|
if (!rect || !selected_channel) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
draw_panel_shadow(renderer, rect);
|
2026-03-28 01:40:11 -04:00
|
|
|
draw_rounded_top_panel(renderer, rect, theme->panel_fill, theme->panel_border, theme->rounded_radius > 0 ? theme->rounded_radius : 8);
|
2026-03-28 00:08:52 -04:00
|
|
|
accent = (SDL_Rect){rect->x + 1, rect->y + 1, rect->w - 2, 38};
|
2026-03-28 01:40:11 -04:00
|
|
|
fill_rect(renderer, &accent, theme->panel_fill);
|
|
|
|
|
draw_panel_bevel(renderer, rect, theme->gloss);
|
|
|
|
|
stroke_rect(renderer, rect, theme->panel_border);
|
2026-03-27 23:33:09 -04:00
|
|
|
|
2026-03-28 00:25:06 -04:00
|
|
|
program = channel_resolve_program(selected_channel, app_start_ticks, now_ticks, &program_seek, NULL);
|
|
|
|
|
if (!program) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
start_time = now_wall - (time_t) program_seek;
|
|
|
|
|
end_time = start_time + (time_t) program->duration_seconds;
|
2026-03-27 23:33:09 -04:00
|
|
|
format_time_compact(time_range, sizeof(time_range), start_time);
|
|
|
|
|
format_time_compact(end_text, sizeof(end_text), end_time);
|
|
|
|
|
strncat(time_range, " - ", sizeof(time_range) - strlen(time_range) - 1);
|
|
|
|
|
strncat(time_range, end_text, sizeof(time_range) - strlen(time_range) - 1);
|
|
|
|
|
snprintf(description,
|
|
|
|
|
sizeof(description),
|
2026-03-28 00:25:06 -04:00
|
|
|
"%s",
|
|
|
|
|
selected_channel->description[0] != '\0' ? selected_channel->description : "Local programming lineup.");
|
2026-03-27 23:33:09 -04:00
|
|
|
|
|
|
|
|
clip_rect = (SDL_Rect){rect->x + 16, rect->y + 12, rect->w - 32, rect->h - 24};
|
2026-03-28 01:40:11 -04:00
|
|
|
draw_text_clipped(renderer, fonts->large, selected_channel->name, &clip_rect, rect->x + 18, rect->y + 44, theme->panel_text);
|
|
|
|
|
draw_text_clipped(renderer, fonts->medium, program->program_title, &clip_rect, rect->x + 18, rect->y + 88, theme->panel_text);
|
|
|
|
|
draw_text_clipped(renderer, fonts->small, time_range, &clip_rect, rect->x + 18, rect->y + 124, theme->panel_text);
|
|
|
|
|
draw_text_clipped(renderer, fonts->small, description, &clip_rect, rect->x + 18, rect->y + 148, theme->panel_text);
|
2026-03-27 23:33:09 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-28 01:40:11 -04:00
|
|
|
static void draw_grid_background(SDL_Renderer *renderer, const GuideTheme *theme, const SDL_Rect *grid_rect, int row_height, double pixels_per_minute) {
|
2026-03-28 00:08:52 -04:00
|
|
|
fill_three_stop_gradient(renderer,
|
|
|
|
|
grid_rect,
|
2026-03-28 01:40:11 -04:00
|
|
|
theme->background_top,
|
|
|
|
|
theme->background_mid,
|
|
|
|
|
theme->background_bottom);
|
2026-03-27 23:33:09 -04:00
|
|
|
|
|
|
|
|
for (int minute = 0; minute <= 90; minute += 30) {
|
|
|
|
|
int x = GUIDE_X_START + (int) (minute * pixels_per_minute);
|
2026-03-28 01:40:11 -04:00
|
|
|
set_draw_color(renderer, theme->grid_line);
|
2026-03-27 23:33:09 -04:00
|
|
|
SDL_RenderDrawLine(renderer, x, grid_rect->y, x, grid_rect->y + grid_rect->h);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (int row = 0; row <= GUIDE_VISIBLE_ROWS; ++row) {
|
|
|
|
|
int y = grid_rect->y + row * row_height;
|
2026-03-28 01:40:11 -04:00
|
|
|
set_draw_color(renderer, theme->row_line_hi);
|
2026-03-27 23:33:09 -04:00
|
|
|
SDL_RenderDrawLine(renderer, grid_rect->x, y, grid_rect->x + grid_rect->w, y);
|
2026-03-28 00:08:52 -04:00
|
|
|
if (y + row_height - 1 <= grid_rect->y + grid_rect->h) {
|
2026-03-28 01:40:11 -04:00
|
|
|
set_draw_color(renderer, theme->row_line_lo);
|
2026-03-28 00:08:52 -04:00
|
|
|
SDL_RenderDrawLine(renderer, grid_rect->x, y + row_height - 1, grid_rect->x + grid_rect->w, y + row_height - 1);
|
|
|
|
|
}
|
2026-03-27 21:45:40 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 01:40:11 -04:00
|
|
|
static void draw_footer_legend(SDL_Renderer *renderer,
|
|
|
|
|
const UiFonts *fonts,
|
|
|
|
|
const GuideTheme *theme,
|
|
|
|
|
int window_width,
|
|
|
|
|
int window_height) {
|
2026-03-27 21:45:40 -04:00
|
|
|
SDL_Rect footer = {0, window_height - 54, window_width, 54};
|
2026-03-28 00:08:52 -04:00
|
|
|
SDL_Rect chip = {window_width / 2 - 180, window_height - 38, 34, 20};
|
2026-03-28 01:40:11 -04:00
|
|
|
SDL_Color footer_text = readable_text_color(theme->footer_mid);
|
2026-03-27 21:45:40 -04:00
|
|
|
|
2026-03-28 01:40:11 -04:00
|
|
|
fill_three_stop_gradient(renderer,
|
|
|
|
|
&footer,
|
|
|
|
|
blend_color(theme->footer_top, theme->footer_mid, 240),
|
|
|
|
|
theme->footer_mid,
|
|
|
|
|
blend_color(theme->footer_bottom, theme->footer_mid, 240));
|
|
|
|
|
draw_gloss_line(renderer, &footer, color_with_alpha(theme->gloss, 20));
|
|
|
|
|
stroke_rect(renderer, &footer, theme->panel_border);
|
2026-03-27 21:45:40 -04:00
|
|
|
|
2026-03-28 01:40:11 -04:00
|
|
|
draw_pill_button(renderer, theme, &chip, theme->panel_fill, theme->panel_border);
|
|
|
|
|
draw_text_clipped(renderer, fonts->medium, "A", &footer, chip.x + 11, chip.y + 1, footer_text);
|
|
|
|
|
draw_text_clipped(renderer, fonts->small, "TIME", &footer, chip.x + 42, chip.y - 1, footer_text);
|
2026-03-27 21:45:40 -04:00
|
|
|
|
2026-03-28 00:08:52 -04:00
|
|
|
chip.x += 144;
|
2026-03-28 01:40:11 -04:00
|
|
|
draw_pill_button(renderer, theme, &chip, theme->block_mid, theme->panel_border);
|
|
|
|
|
draw_text_clipped(renderer, fonts->medium, "B", &footer, chip.x + 11, chip.y + 1, footer_text);
|
|
|
|
|
draw_text_clipped(renderer, fonts->small, "THEME", &footer, chip.x + 42, chip.y - 1, footer_text);
|
2026-03-27 21:45:40 -04:00
|
|
|
|
2026-03-28 00:08:52 -04:00
|
|
|
chip.x += 150;
|
2026-03-28 01:40:11 -04:00
|
|
|
draw_pill_button(renderer, theme, &chip, theme->row_active_mid, theme->panel_border);
|
|
|
|
|
draw_text_clipped(renderer, fonts->medium, "C", &footer, chip.x + 11, chip.y + 1, footer_text);
|
|
|
|
|
draw_text_clipped(renderer, fonts->small, "TITLE", &footer, chip.x + 42, chip.y - 1, footer_text);
|
2026-03-28 00:08:52 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-28 01:40:11 -04:00
|
|
|
static void draw_scanline_overlay(SDL_Renderer *renderer, int width, int height, const GuideTheme *theme) {
|
|
|
|
|
(void) renderer;
|
|
|
|
|
(void) width;
|
|
|
|
|
(void) height;
|
|
|
|
|
(void) theme;
|
2026-03-27 21:45:40 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-28 00:45:10 -04:00
|
|
|
void ui_render_no_media(SDL_Renderer *renderer, const UiCache *cache, int window_width, int window_height) {
|
|
|
|
|
SDL_Rect full = {0, 0, window_width, window_height};
|
2026-03-27 21:45:40 -04:00
|
|
|
fill_rect(renderer, &full, COLOR_NAVY_DARK);
|
2026-03-27 23:19:42 -04:00
|
|
|
draw_cached_text(renderer, &cache->no_media_title, 58, 80);
|
|
|
|
|
draw_cached_text(renderer, &cache->no_media_body, 58, 136);
|
|
|
|
|
draw_cached_text(renderer, &cache->no_media_hint, 58, 176);
|
2026-03-27 21:45:40 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-28 00:45:10 -04:00
|
|
|
void ui_render_fullscreen(SDL_Renderer *renderer,
|
|
|
|
|
SDL_Texture *video_texture,
|
|
|
|
|
int texture_width,
|
|
|
|
|
int texture_height,
|
|
|
|
|
int window_width,
|
|
|
|
|
int window_height) {
|
|
|
|
|
SDL_Rect window = {0, 0, window_width, window_height};
|
2026-03-27 21:45:40 -04:00
|
|
|
fill_rect(renderer, &window, COLOR_BLACK);
|
|
|
|
|
draw_video(renderer, video_texture, texture_width, texture_height, window);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ui_render_guide(SDL_Renderer *renderer,
|
|
|
|
|
SDL_Texture *video_texture,
|
|
|
|
|
int texture_width,
|
|
|
|
|
int texture_height,
|
2026-03-28 00:45:10 -04:00
|
|
|
int window_width,
|
|
|
|
|
int window_height,
|
2026-03-28 01:40:11 -04:00
|
|
|
const GuideTheme *theme,
|
2026-03-27 21:45:40 -04:00
|
|
|
const UiFonts *fonts,
|
2026-03-27 23:19:42 -04:00
|
|
|
UiCache *cache,
|
2026-03-27 21:45:40 -04:00
|
|
|
const ChannelList *channels,
|
|
|
|
|
int active_channel,
|
2026-03-27 23:19:42 -04:00
|
|
|
Uint64 app_start_ticks,
|
|
|
|
|
Uint64 now_ticks,
|
|
|
|
|
time_t now_wall) {
|
2026-03-28 00:45:10 -04:00
|
|
|
double scale_x = (double) window_width / WINDOW_WIDTH;
|
|
|
|
|
double scale_y = (double) window_height / WINDOW_HEIGHT;
|
|
|
|
|
int guide_x_start = (int) (GUIDE_X_START * scale_x);
|
|
|
|
|
int sidebar_width = (int) (GUIDE_SIDEBAR_WIDTH * scale_x);
|
|
|
|
|
SDL_Rect full = {0, 0, window_width, window_height};
|
|
|
|
|
SDL_Rect info_panel = {0, 0, (int) (GUIDE_INFO_WIDTH * scale_x), (int) (GUIDE_INFO_HEIGHT * scale_y)};
|
|
|
|
|
SDL_Rect preview = {window_width - (int) (GUIDE_PREVIEW_WIDTH * scale_x), 0, (int) (GUIDE_PREVIEW_WIDTH * scale_x), (int) (GUIDE_PREVIEW_HEIGHT * scale_y)};
|
|
|
|
|
SDL_Rect status_bar = {info_panel.w, 0, window_width - info_panel.w, (int) (GUIDE_STATUS_HEIGHT * scale_y)};
|
|
|
|
|
SDL_Rect header_row = {0, (int) ((GUIDE_GRID_TOP - GUIDE_STATUS_HEIGHT) * scale_y), window_width, (int) (GUIDE_STATUS_HEIGHT * scale_y)};
|
|
|
|
|
SDL_Rect grid = {0, (int) (GUIDE_GRID_TOP * scale_y), window_width, window_height - (int) (GUIDE_GRID_TOP * scale_y) - (int) (GUIDE_FOOTER_HEIGHT * scale_y)};
|
2026-03-27 21:45:40 -04:00
|
|
|
int row_height = grid.h / GUIDE_VISIBLE_ROWS;
|
2026-03-28 00:45:10 -04:00
|
|
|
int timeline_w = window_width - guide_x_start - (int) (20 * scale_x);
|
2026-03-27 21:45:40 -04:00
|
|
|
int start_index = active_channel - 2;
|
2026-03-27 23:33:09 -04:00
|
|
|
const Channel *selected_channel = NULL;
|
|
|
|
|
double pixels_per_minute = timeline_w / 90.0;
|
2026-03-27 21:45:40 -04:00
|
|
|
|
|
|
|
|
if (channels && channels->count > 0 && active_channel >= 0 && active_channel < channels->count) {
|
2026-03-27 23:33:09 -04:00
|
|
|
selected_channel = &channels->items[active_channel];
|
2026-03-27 21:45:40 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-28 00:08:52 -04:00
|
|
|
fill_three_stop_gradient(renderer,
|
|
|
|
|
&full,
|
2026-03-28 01:40:11 -04:00
|
|
|
theme->background_top,
|
|
|
|
|
theme->background_mid,
|
|
|
|
|
theme->background_bottom);
|
|
|
|
|
draw_info_panel(renderer, fonts, theme, selected_channel, &info_panel, app_start_ticks, now_ticks, now_wall);
|
2026-03-27 23:33:09 -04:00
|
|
|
draw_panel_shadow(renderer, &preview);
|
|
|
|
|
fill_rect(renderer, &preview, COLOR_BLACK);
|
|
|
|
|
draw_video(renderer, video_texture, texture_width, texture_height, preview);
|
2026-03-28 01:40:11 -04:00
|
|
|
draw_status_bar(renderer, fonts->medium, theme, selected_channel, &status_bar, now_wall);
|
2026-03-27 23:33:09 -04:00
|
|
|
|
2026-03-27 23:19:42 -04:00
|
|
|
if (cache->timeline_label_slot != now_wall / 60) {
|
|
|
|
|
char label[32];
|
|
|
|
|
for (int i = 0; i < 4; ++i) {
|
|
|
|
|
format_clock_label(label, sizeof(label), now_wall, (int) ((TIMELINE_VISIBLE_SECONDS / 60.0 / 4) * i));
|
|
|
|
|
text_texture_destroy(&cache->timeline_labels[i]);
|
2026-03-28 01:40:11 -04:00
|
|
|
text_texture_init(&cache->timeline_labels[i], renderer, fonts->small, label, theme->ribbon_text);
|
2026-03-27 23:19:42 -04:00
|
|
|
}
|
|
|
|
|
cache->timeline_label_slot = now_wall / 60;
|
|
|
|
|
}
|
2026-03-28 01:40:11 -04:00
|
|
|
draw_timeline_header_cached(renderer, cache, theme, header_row);
|
|
|
|
|
draw_grid_background(renderer, theme, &grid, row_height, pixels_per_minute);
|
2026-03-27 21:45:40 -04:00
|
|
|
|
|
|
|
|
if (start_index < 0) {
|
|
|
|
|
start_index = 0;
|
|
|
|
|
}
|
|
|
|
|
if (channels && start_index + GUIDE_VISIBLE_ROWS > channels->count) {
|
|
|
|
|
start_index = SDL_max(0, channels->count - GUIDE_VISIBLE_ROWS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (int row = 0; row < GUIDE_VISIBLE_ROWS; ++row) {
|
|
|
|
|
int channel_index = start_index + row;
|
2026-03-28 00:45:10 -04:00
|
|
|
SDL_Rect row_rect = {0, grid.y + row * row_height, window_width, row_height};
|
|
|
|
|
SDL_Rect sidebar = {0, row_rect.y, sidebar_width, row_height};
|
|
|
|
|
SDL_Rect timeline_rect = {guide_x_start, row_rect.y + (int) (6 * scale_y), timeline_w, row_height - (int) (12 * scale_y)};
|
2026-03-27 21:45:40 -04:00
|
|
|
SDL_Rect clip = timeline_rect;
|
|
|
|
|
int is_selected = channel_index == active_channel;
|
2026-03-27 23:33:09 -04:00
|
|
|
SDL_Rect inset = {row_rect.x + 4, row_rect.y + 3, row_rect.w - 8, row_rect.h - 6};
|
2026-03-27 21:45:40 -04:00
|
|
|
|
2026-03-28 00:08:52 -04:00
|
|
|
draw_beveled_bar(renderer,
|
|
|
|
|
&row_rect,
|
2026-03-28 01:40:11 -04:00
|
|
|
is_selected ? theme->row_active_top : theme->row_top,
|
|
|
|
|
is_selected ? theme->row_active_mid : theme->row_mid,
|
|
|
|
|
is_selected ? theme->row_active_bottom : theme->row_bottom,
|
|
|
|
|
theme->gloss,
|
|
|
|
|
theme->panel_border);
|
2026-03-27 23:33:09 -04:00
|
|
|
fill_rect_alpha(renderer, &inset, (SDL_Color){255, 255, 255, is_selected ? 12 : 6});
|
2026-03-28 00:08:52 -04:00
|
|
|
draw_beveled_bar(renderer,
|
|
|
|
|
&sidebar,
|
2026-03-28 01:40:11 -04:00
|
|
|
is_selected ? theme->row_active_top : theme->row_top,
|
|
|
|
|
is_selected ? theme->row_active_mid : theme->row_mid,
|
|
|
|
|
is_selected ? theme->row_active_bottom : theme->row_bottom,
|
|
|
|
|
theme->gloss,
|
|
|
|
|
theme->panel_border);
|
2026-03-27 21:45:40 -04:00
|
|
|
|
|
|
|
|
if (!channels || channel_index >= channels->count) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
const Channel *channel = &channels->items[channel_index];
|
2026-03-28 00:25:06 -04:00
|
|
|
double live_position = 0.0;
|
|
|
|
|
double program_seek = 0.0;
|
|
|
|
|
const ProgramEntry *program = channel_resolve_program(channel, app_start_ticks, now_ticks, &program_seek, NULL);
|
2026-03-27 23:33:09 -04:00
|
|
|
time_t guide_view_start_time = now_wall - (30 * 60);
|
|
|
|
|
time_t program_start_time = now_wall - (time_t) live_position;
|
2026-03-28 00:45:10 -04:00
|
|
|
int block_x = guide_x_start;
|
2026-03-28 00:25:06 -04:00
|
|
|
int block_w = 48;
|
2026-03-28 00:45:10 -04:00
|
|
|
SDL_Rect block = {guide_x_start, timeline_rect.y + 4, 48, timeline_rect.h - 8};
|
2026-03-27 23:33:09 -04:00
|
|
|
SDL_Rect title_rect = {block.x + 8, block.y + 8, block.w - 16, block.h - 16};
|
|
|
|
|
char title[128];
|
2026-03-27 21:45:40 -04:00
|
|
|
|
2026-03-28 00:25:06 -04:00
|
|
|
if (!program) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
live_position = channel_live_position_precise(channel, app_start_ticks, now_ticks);
|
|
|
|
|
program_start_time = now_wall - (time_t) program_seek;
|
2026-03-28 00:45:10 -04:00
|
|
|
block_x = guide_x_start + (int) (((double) (program_start_time - guide_view_start_time)) / 60.0 * pixels_per_minute);
|
2026-03-28 00:25:06 -04:00
|
|
|
block_w = (int) ((program->duration_seconds / 60.0) * pixels_per_minute);
|
|
|
|
|
block = (SDL_Rect){block_x, timeline_rect.y + 4, SDL_max(block_w, 48), timeline_rect.h - 8};
|
|
|
|
|
title_rect = (SDL_Rect){block.x + 8, block.y + 8, block.w - 16, block.h - 16};
|
|
|
|
|
|
2026-03-28 00:08:52 -04:00
|
|
|
if (is_selected) {
|
2026-03-28 01:40:11 -04:00
|
|
|
draw_text_clipped(renderer, fonts->medium, channel->name, &sidebar, 20, row_rect.y + 12, theme->row_active_text);
|
2026-03-28 00:08:52 -04:00
|
|
|
{
|
|
|
|
|
char number[16];
|
|
|
|
|
snprintf(number, sizeof(number), "%d", channel->number);
|
2026-03-28 01:40:11 -04:00
|
|
|
draw_text_clipped(renderer, fonts->medium, number, &sidebar, 176, row_rect.y + 12, theme->row_active_text);
|
2026-03-28 00:08:52 -04:00
|
|
|
}
|
|
|
|
|
} else {
|
2026-03-28 01:40:11 -04:00
|
|
|
draw_text_clipped(renderer, fonts->medium, channel->name, &sidebar, 20, row_rect.y + 12, theme->row_text);
|
|
|
|
|
{
|
|
|
|
|
char number[16];
|
|
|
|
|
snprintf(number, sizeof(number), "%d", channel->number);
|
|
|
|
|
draw_text_clipped(renderer, fonts->medium, number, &sidebar, 176, row_rect.y + 12, theme->row_text);
|
|
|
|
|
}
|
2026-03-28 00:08:52 -04:00
|
|
|
}
|
2026-03-28 01:40:11 -04:00
|
|
|
draw_text_clipped(renderer,
|
|
|
|
|
fonts->small,
|
|
|
|
|
program->file_name,
|
|
|
|
|
&sidebar,
|
|
|
|
|
20,
|
|
|
|
|
row_rect.y + 38,
|
|
|
|
|
secondary_text_color(theme->row_text, is_selected ? theme->row_active_mid : theme->row_mid));
|
2026-03-27 21:45:40 -04:00
|
|
|
|
|
|
|
|
SDL_RenderSetClipRect(renderer, &clip);
|
2026-03-28 00:08:52 -04:00
|
|
|
draw_beveled_bar(renderer,
|
|
|
|
|
&block,
|
2026-03-28 01:40:11 -04:00
|
|
|
is_selected ? theme->block_active_top : theme->block_top,
|
|
|
|
|
is_selected ? theme->block_active_mid : theme->block_mid,
|
|
|
|
|
is_selected ? theme->block_active_bottom : theme->block_bottom,
|
|
|
|
|
is_selected ? theme->selection_edge : theme->gloss,
|
|
|
|
|
theme->panel_border);
|
2026-03-27 23:33:09 -04:00
|
|
|
fit_text_with_ellipsis(is_selected ? fonts->medium : fonts->small,
|
2026-03-28 00:25:06 -04:00
|
|
|
program->program_title,
|
2026-03-27 23:33:09 -04:00
|
|
|
title_rect.w,
|
|
|
|
|
title,
|
|
|
|
|
sizeof(title));
|
|
|
|
|
draw_text_clipped(renderer,
|
|
|
|
|
is_selected ? fonts->medium : fonts->small,
|
|
|
|
|
title,
|
|
|
|
|
&title_rect,
|
|
|
|
|
title_rect.x,
|
|
|
|
|
title_rect.y,
|
2026-03-28 01:40:11 -04:00
|
|
|
is_selected ? theme->block_active_text : theme->block_text);
|
2026-03-27 21:45:40 -04:00
|
|
|
SDL_RenderSetClipRect(renderer, NULL);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 23:33:09 -04:00
|
|
|
if (selected_channel && active_channel >= 0 && start_index <= active_channel && active_channel < start_index + GUIDE_VISIBLE_ROWS) {
|
2026-03-28 00:45:10 -04:00
|
|
|
SDL_Rect highlight = {0, grid.y + (active_channel - start_index) * row_height, window_width, row_height};
|
2026-03-28 01:40:11 -04:00
|
|
|
draw_selection_glow(renderer, &highlight, theme->selection_edge);
|
|
|
|
|
draw_selection_glow(renderer, &(SDL_Rect){guide_x_start, highlight.y + (int) (6 * scale_y), timeline_w, row_height - (int) (12 * scale_y)}, theme->selection_edge);
|
2026-03-27 23:33:09 -04:00
|
|
|
}
|
|
|
|
|
|
2026-03-28 01:40:11 -04:00
|
|
|
draw_footer_legend(renderer, fonts, theme, window_width, window_height);
|
|
|
|
|
draw_scanline_overlay(renderer, window_width, window_height, theme);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ui_render_theme_picker(SDL_Renderer *renderer,
|
|
|
|
|
const UiFonts *fonts,
|
|
|
|
|
int window_width,
|
|
|
|
|
int window_height,
|
|
|
|
|
const GuideTheme *active_theme,
|
|
|
|
|
int current_theme_index,
|
|
|
|
|
int selected_theme_index) {
|
|
|
|
|
SDL_Rect overlay = {0, 0, window_width, window_height};
|
|
|
|
|
SDL_Rect modal = {window_width / 2 - 250, window_height / 2 - 180, 500, 360};
|
|
|
|
|
SDL_Rect title_bar = {modal.x, modal.y, modal.w, 38};
|
|
|
|
|
|
|
|
|
|
(void) current_theme_index;
|
|
|
|
|
fill_rect_alpha(renderer, &overlay, (SDL_Color){0, 0, 0, 120});
|
|
|
|
|
draw_panel_shadow(renderer, &modal);
|
|
|
|
|
draw_rounded_top_panel(renderer, &modal, active_theme->panel_fill, active_theme->panel_border, active_theme->rounded_radius > 0 ? active_theme->rounded_radius : 8);
|
|
|
|
|
fill_rect(renderer, &(SDL_Rect){modal.x, modal.y + 38, modal.w, modal.h - 38}, active_theme->footer_mid);
|
|
|
|
|
draw_beveled_bar(renderer, &title_bar, active_theme->ribbon_top, active_theme->ribbon_mid, active_theme->ribbon_bottom, active_theme->gloss, active_theme->panel_border);
|
|
|
|
|
draw_text_clipped(renderer, fonts->medium, "THEME SELECT", &modal, modal.x + 16, modal.y + 10, active_theme->ribbon_text);
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < GUIDE_THEME_COUNT; ++i) {
|
|
|
|
|
SDL_Rect item = {modal.x + 18, modal.y + 52 + i * 28, modal.w - 36, 24};
|
|
|
|
|
SDL_Rect swatch = {modal.x + modal.w - 118, item.y + 4, 92, 14};
|
|
|
|
|
char label[96];
|
|
|
|
|
const GuideTheme *theme = &GUIDE_THEMES[i];
|
|
|
|
|
|
|
|
|
|
if (i == selected_theme_index) {
|
|
|
|
|
draw_beveled_bar(renderer, &item, theme->row_active_top, theme->row_active_mid, theme->row_active_bottom, theme->gloss, theme->panel_border);
|
|
|
|
|
draw_selection_glow(renderer, &item, theme->selection_edge);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
snprintf(label, sizeof(label), "%c %s", i == selected_theme_index ? '>' : ' ', theme->name);
|
|
|
|
|
draw_text_clipped(renderer,
|
|
|
|
|
fonts->small,
|
|
|
|
|
label,
|
|
|
|
|
&item,
|
|
|
|
|
item.x + 8,
|
|
|
|
|
item.y + 4,
|
|
|
|
|
i == selected_theme_index ? theme->row_active_text : active_theme->panel_text);
|
|
|
|
|
fill_three_stop_gradient(renderer, &swatch, theme->background_top, theme->background_mid, theme->background_bottom);
|
|
|
|
|
stroke_rect(renderer, &swatch, theme->panel_border);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
draw_text_clipped(renderer,
|
|
|
|
|
fonts->small,
|
|
|
|
|
"ENTER APPLY ESC CANCEL B CLOSE",
|
|
|
|
|
&modal,
|
|
|
|
|
modal.x + 18,
|
|
|
|
|
modal.y + modal.h - 28,
|
|
|
|
|
active_theme->panel_text);
|
2026-03-27 23:19:42 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int ui_cache_init(UiCache *cache, SDL_Renderer *renderer, const UiFonts *fonts, const ChannelList *channels) {
|
|
|
|
|
char buffer[256];
|
|
|
|
|
|
|
|
|
|
if (!cache || !renderer || !fonts) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
memset(cache, 0, sizeof(*cache));
|
|
|
|
|
cache->renderer = renderer;
|
|
|
|
|
cache->timeline_label_slot = -1;
|
|
|
|
|
cache->timeline_labels = calloc(4, sizeof(UiTextTexture));
|
|
|
|
|
if (!cache->timeline_labels) {
|
|
|
|
|
ui_cache_destroy(cache);
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (text_texture_init(&cache->no_media_title, renderer, fonts->large, "Passport-C Media Player", COLOR_TEXT_LIGHT) != 0 ||
|
|
|
|
|
text_texture_init(&cache->no_media_body, renderer, fonts->medium, "No channels found in ./media", COLOR_HIGHLIGHT_YELLOW) != 0 ||
|
|
|
|
|
text_texture_init(&cache->no_media_hint, renderer, fonts->small, "Add MP4 or MKV files to ./media and relaunch.", COLOR_PALE_BLUE) != 0 ||
|
|
|
|
|
text_texture_init(&cache->footer_a, renderer, fonts->medium, "A", COLOR_TEXT_DARK) != 0 ||
|
2026-03-28 00:08:52 -04:00
|
|
|
text_texture_init(&cache->footer_time, renderer, fonts->small, "TIME", COLOR_TEXT_DARK) != 0 ||
|
2026-03-27 23:19:42 -04:00
|
|
|
text_texture_init(&cache->footer_b, renderer, fonts->medium, "B", COLOR_TEXT_LIGHT) != 0 ||
|
2026-03-28 00:08:52 -04:00
|
|
|
text_texture_init(&cache->footer_theme, renderer, fonts->small, "THEME", COLOR_TEXT_DARK) != 0 ||
|
2026-03-27 23:19:42 -04:00
|
|
|
text_texture_init(&cache->footer_c, renderer, fonts->medium, "C", COLOR_TEXT_LIGHT) != 0 ||
|
2026-03-28 00:08:52 -04:00
|
|
|
text_texture_init(&cache->footer_title, renderer, fonts->small, "TITLE", COLOR_TEXT_DARK) != 0) {
|
2026-03-27 23:19:42 -04:00
|
|
|
ui_cache_destroy(cache);
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (channels && channels->count > 0) {
|
|
|
|
|
cache->channels = calloc((size_t) channels->count, sizeof(UiChannelCache));
|
|
|
|
|
if (!cache->channels) {
|
|
|
|
|
ui_cache_destroy(cache);
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
cache->channel_count = channels->count;
|
|
|
|
|
|
|
|
|
|
for (int i = 0; i < channels->count; ++i) {
|
|
|
|
|
const Channel *channel = &channels->items[i];
|
2026-03-28 00:25:06 -04:00
|
|
|
const ProgramEntry *program = channel->program_count > 0 ? &channel->programs[0] : NULL;
|
2026-03-27 23:19:42 -04:00
|
|
|
snprintf(buffer, sizeof(buffer), "%d", channel->number);
|
|
|
|
|
if (text_texture_init(&cache->channels[i].name_medium, renderer, fonts->medium, channel->name, COLOR_TEXT_LIGHT) != 0 ||
|
|
|
|
|
text_texture_init(&cache->channels[i].number_medium, renderer, fonts->medium, buffer, COLOR_TEXT_LIGHT) != 0 ||
|
2026-03-28 00:25:06 -04:00
|
|
|
text_texture_init(&cache->channels[i].file_small, renderer, fonts->small, program ? program->file_name : "", COLOR_PALE_BLUE) != 0 ||
|
|
|
|
|
text_texture_init(&cache->channels[i].program_small_light, renderer, fonts->small, program ? program->program_title : "", COLOR_TEXT_LIGHT) != 0 ||
|
|
|
|
|
text_texture_init(&cache->channels[i].program_medium_dark, renderer, fonts->medium, program ? program->program_title : "", COLOR_TEXT_DARK) != 0) {
|
2026-03-27 23:19:42 -04:00
|
|
|
ui_cache_destroy(cache);
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 00:25:06 -04:00
|
|
|
snprintf(buffer, sizeof(buffer), "%.0f min - %s", program ? program->duration_seconds / 60.0 : 0.0, program ? program->file_name : "");
|
2026-03-27 23:19:42 -04:00
|
|
|
if (text_texture_init(&cache->channels[i].detail_small_dark, renderer, fonts->small, buffer, COLOR_TEXT_DARK) != 0) {
|
|
|
|
|
ui_cache_destroy(cache);
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ui_cache_destroy(UiCache *cache) {
|
|
|
|
|
if (!cache) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
text_texture_destroy(&cache->no_media_title);
|
|
|
|
|
text_texture_destroy(&cache->no_media_body);
|
|
|
|
|
text_texture_destroy(&cache->no_media_hint);
|
|
|
|
|
text_texture_destroy(&cache->footer_a);
|
|
|
|
|
text_texture_destroy(&cache->footer_time);
|
|
|
|
|
text_texture_destroy(&cache->footer_b);
|
|
|
|
|
text_texture_destroy(&cache->footer_theme);
|
|
|
|
|
text_texture_destroy(&cache->footer_c);
|
|
|
|
|
text_texture_destroy(&cache->footer_title);
|
|
|
|
|
|
|
|
|
|
if (cache->timeline_labels) {
|
|
|
|
|
for (int i = 0; i < 4; ++i) {
|
|
|
|
|
text_texture_destroy(&cache->timeline_labels[i]);
|
|
|
|
|
}
|
|
|
|
|
free(cache->timeline_labels);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (cache->channels) {
|
|
|
|
|
for (int i = 0; i < cache->channel_count; ++i) {
|
|
|
|
|
text_texture_destroy(&cache->channels[i].name_medium);
|
|
|
|
|
text_texture_destroy(&cache->channels[i].number_medium);
|
|
|
|
|
text_texture_destroy(&cache->channels[i].file_small);
|
|
|
|
|
text_texture_destroy(&cache->channels[i].program_small_light);
|
|
|
|
|
text_texture_destroy(&cache->channels[i].program_medium_dark);
|
|
|
|
|
text_texture_destroy(&cache->channels[i].detail_small_dark);
|
|
|
|
|
}
|
|
|
|
|
free(cache->channels);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
memset(cache, 0, sizeof(*cache));
|
2026-03-27 21:45:40 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int ui_load_fonts(UiFonts *fonts) {
|
|
|
|
|
if (!fonts) {
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
memset(fonts, 0, sizeof(*fonts));
|
|
|
|
|
for (size_t i = 0; i < sizeof(FONT_CANDIDATES) / sizeof(FONT_CANDIDATES[0]); ++i) {
|
|
|
|
|
fonts->small = TTF_OpenFont(FONT_CANDIDATES[i], 18);
|
|
|
|
|
fonts->medium = TTF_OpenFont(FONT_CANDIDATES[i], 24);
|
|
|
|
|
fonts->large = TTF_OpenFont(FONT_CANDIDATES[i], 32);
|
|
|
|
|
if (fonts->small && fonts->medium && fonts->large) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
ui_destroy_fonts(fonts);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return -1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ui_destroy_fonts(UiFonts *fonts) {
|
|
|
|
|
if (!fonts) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (fonts->small) {
|
|
|
|
|
TTF_CloseFont(fonts->small);
|
|
|
|
|
}
|
|
|
|
|
if (fonts->medium) {
|
|
|
|
|
TTF_CloseFont(fonts->medium);
|
|
|
|
|
}
|
|
|
|
|
if (fonts->large) {
|
|
|
|
|
TTF_CloseFont(fonts->large);
|
|
|
|
|
}
|
|
|
|
|
memset(fonts, 0, sizeof(*fonts));
|
|
|
|
|
}
|