From ba7a30c41d50adf9a25e8a58919387b2ebceaf47 Mon Sep 17 00:00:00 2001 From: Sads Date: Sat, 13 Jun 2026 14:46:07 +0200 Subject: [PATCH] Smart search bar --- CMakeLists.txt | 1 + assets/sce_sys/icon0.png | Bin 7562 -> 2452 bytes src/main.c | 542 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 523 insertions(+), 20 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7d9e006..4a0d660 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,7 @@ target_link_libraries(${PROJECT_NAME} SceCtrl_stub SceTouch_stub SceCommonDialog_stub + SceIme_stub ScePgf_stub SceAppMgr_stub SceNet_stub diff --git a/assets/sce_sys/icon0.png b/assets/sce_sys/icon0.png index 6f60df5764b87bfb7f97c884509e48e621a15b81..879b7f3dbe6c57c4dd581cf224b25d3f945b16ec 100644 GIT binary patch delta 2302 zcmZXVdpOhW8^FKY%&;;;&R#Z@QlTlQ#UfKqdn+5m3m0N(Dj=kV*lO2oNbyD1cB1 zL?VD7Kr8}MF<>(RhYi?lK!kuog;E^_P#{1d0&*cxNL48ymMer(1xE@*2$0GFlL`;!fPIb zrb0bq^`fc@WzR2QQAL?0TT}9l9e#|u_7?x9;alrGE}!o6kEnKRGTL*(v1#Tpd0k$i z|K{sKyWBiHk`u_>n$c-J*!R>h6a)=q4hOX#!*Lpa9bo4S2gKOB6w}wGBpJ?A$}Oc= zNlM#;KfA44uWxlZFrWC8UD)7JXU9)9vjJA?7HM(GZof3OxH^L8FG!o$6WrG`8YcwH zpmHbRWb$kKsT|M7>yo{GAB=ZHC4_Mr+45v^$kT*oPZ3IQ%cn57@-phxq=hl=7gr6< zzOe#QLui(kvOR2Qcus&WHS#-Tc39(0tCc8+ptp2~W{m;gf6c{~$livTtqJ;)lDQ(Q zRLd6oPX>mEW}oDac3&?ID`mWLd))G9U`ypRsBcJSunZctc(#?bX4#LdC9Fk$w~P9R zZ+wU5>Nk1CT@8Yx3aYGU0++i7YarLcT*?O4?)rv@bb~*OW`Hi5Wgp+QWJ#{F^L;@` z{mX(nG-NRHg+fDRy*o3OB^eXwm$~npOxij{qOX8j2-T6He-QSlRfc9wMnLx*lTHrd zlB+T&x{_10iTHSfU3mm8oiAxs_m<^`;Yh_#w>2=3aoGFWF{gP`wDC8ZloIfL>C^Ux z1#2BWghH>VKddp5s+Xh>$NBGZ0eA*YU+c>J&;f4vyUh*#Bg+dnJxRkZ4Rbx=xn4$i z6L_bxcmQ*n@F-xKus+ zR4++r#KA?6H4BYdPb0l<#S*c#Wy6MoZO@HZ#w^+IC4PggcP8*Rhfvs8xLbT@7Ps2R zM04oyVysVhD4qwOVPJHwnVs#LF~|0Gs&~Z1>b2U(_xUYcz?QY^wYcUQ`r7uM!K$^! zyxwiIeeTVemams~0;(X|UJ;uVAK-RhXUQ6VF6`5LB`Va=&or~9CxJc_9d4jjPl)PS z8Mr(eJ-y?4*by1)+;jQ?QloYDv3{?<`30lq73aJ*FF*xDi_#4^)B4f4Fp3Wc!%Pm3uVX>>^PwKg^$t^MUTlR+q>9%E$O-PX*Sejw^yu1n^{Us)Yu;mAxms)Q(1?){A)MI)tcmX6SacfHE@& zS_$zCn#RZc<-T=E+wE@u5$C?Jjkj8w?aRz|tJ#m-kN`EnyaOl)@a6v`!9U&jQ! z|Ma|l1)hcBjW=y(zF^yzUc%kd=1tHeVXsf=DW)c|HUrk)KFo~PY(oYqj1CEE%*m)Y zqi<~*E~b87x31`HE9=;JO+(hwmU_OvuZ{Dzha-qu+6_WjjylWBc-4R7w;VdEpVCOZyArW9sB z6y%nOm7PC~Oh?9u4da8;^xUKTO6XK=!nYl^KSQk~p1kS9+cWNcejk$`q8#&pJ34UR zOHAwRYc>+9!G|rZf-#hh-B)*j{`Rn<`Uf=2Q8vlfSUG8q`q(Jz$(b5ji`C)m; ppOZJ0f6qS%^89z48F4ZI)PL+#?6Z#+bE=69`O-F1D}BN;{tKW@UhMz? delta 7456 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 #include #include +#include +#include #include #include @@ -57,6 +59,7 @@ #define ROW_H 200 /* justified-grid row height; widths follow aspect */ #define CELL_PAD 3 #define HEADER_H 40 /* month/year band height in the grid */ +#define SEARCH_H 56 /* pinned Immich-style search bar at the top */ #define THUMB_MAX 256 /* decode grid thumbs down to <= this dimension */ #define FULL_MAX 4096 /* GXM texture size limit */ @@ -174,6 +177,18 @@ static int g_sect_count; static int g_sect_cap; static float g_content_h; /* total scrollable height incl. headers */ +/* free-text smart (CLIP) search. when active the grid shows only the matching + * server assets — still date-grouped under month headers like the timeline — + * instead of the full merged library; TRIANGLE opens the keyboard, CIRCLE (or + * the bar's clear chip) returns to the timeline. g_search_idx holds indices + * into the g_asset_* arrays (those never reorder, only grow), in the order the + * server returned them. */ +static int g_search_active; +static char g_search_query[128]; +static int *g_search_idx; +static int g_search_count; +static int g_search_cap; + /* Texture recycling pool. Freeing a texture unmaps its memblock, and the * GPU side (notably Vita3K's texture cache, which re-reads guest memory of * cached textures at its own pace) may still touch it afterwards — freeing @@ -1511,7 +1526,9 @@ static void layout_finish_row(int start, int end, float *y, float natw, static void layout_grid(void) { g_sect_count = 0; - float y = 0.0f, natw = 0.0f; + /* start below the pinned search bar so the first month header clears + * it at the top of the scroll range */ + float y = SEARCH_H, natw = 0.0f; int row_start = 0; char curkey[8] = ""; @@ -1558,6 +1575,23 @@ static void layout_grid(void) * server asset is shown once, as the server asset (green badge). */ static void rebuild_display(void) { + /* search mode: the merged timeline is replaced by the result set + * (server assets only, date-grouped); local media isn't folded in */ + if (g_search_active) { + if (!grow_disp(g_search_count > 0 ? g_search_count : 1)) + return; + int n = 0; + for (int k = 0; k < g_search_count; k++) { + g_disp[n].src = SRC_SERVER; + g_disp[n].idx = g_search_idx[k]; + n++; + } + g_disp_count = n; + qsort(g_disp, n, sizeof(g_disp[0]), disp_cmp); + layout_grid(); + return; + } + for (int i = 0; i < g_asset_count; i++) g_asset_local_backed[i] = 0; @@ -1812,6 +1846,365 @@ static void draw_sel_outline(float x, float y, float w, float h) vita2d_draw_rectangle(x + w - t, y, t, h, c); } +/* ---- Immich-style top search bar ----------------------------------- */ + +/* an anti-aliased filled rounded rectangle, baked once into a white alpha + * mask texture (4x4 supersampled corner coverage) and drawn tinted to the + * wanted colour. vita2d has no AA primitives, so the coverage mask + the + * texture's bilinear sampling are what soften the corners. */ +static vita2d_texture *rounded_mask_tex(int w, int h, float r) +{ + static vita2d_texture *cache; + static int cw, ch; + static float cr; + if (cache && cw == w && ch == h && cr == r) + return cache; + if (cache) { + vita2d_wait_rendering_done(); + vita2d_free_texture(cache); + cache = NULL; + } + vita2d_texture *t = vita2d_create_empty_texture_format(w, h, + SCE_GXM_TEXTURE_FORMAT_A8B8G8R8); + if (!t) + return NULL; + uint32_t *data = vita2d_texture_get_datap(t); + int stride = (int)(vita2d_texture_get_stride(t) / 4); /* pixels/row */ + memset(data, 0, (size_t)stride * h * 4); /* keep row padding transparent */ + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int hits = 0; + for (int sy = 0; sy < 4; sy++) + for (int sx = 0; sx < 4; sx++) { + float px = x + (sx + 0.5f) / 4.0f; + float py = y + (sy + 0.5f) / 4.0f; + /* nearest point of the corner-centre + * rectangle inset by r */ + float qx = px < r ? r : + (px > w - r ? w - r : px); + float qy = py < r ? r : + (py > h - r ? h - r : py); + float dx = px - qx, dy = py - qy; + if (dx * dx + dy * dy <= r * r) + hits++; + } + data[y * stride + x] = RGBA8(255, 255, 255, + hits * 255 / 16); + } + } + cache = t; + cw = w; + ch = h; + cr = r; + return cache; +} + +/* a line-art magnifying glass baked into an AA white mask (4x4 supersampled): + * a thin ring outline plus a straight, round-capped handle off the lower + * right. drawn tinted to the icon colour. cached at one size. */ +static vita2d_texture *search_icon_tex(int s) +{ + static vita2d_texture *cache; + static int cs; + if (cache && cs == s) + return cache; + if (cache) { + vita2d_wait_rendering_done(); + vita2d_free_texture(cache); + cache = NULL; + } + vita2d_texture *t = vita2d_create_empty_texture_format(s, s, + SCE_GXM_TEXTURE_FORMAT_A8B8G8R8); + if (!t) + return NULL; + uint32_t *data = vita2d_texture_get_datap(t); + int stride = (int)(vita2d_texture_get_stride(t) / 4); + memset(data, 0, (size_t)stride * s * 4); + + float R = s * 0.30f; /* ring outer radius */ + float W = s * 0.11f; /* stroke width */ + if (W < 1.8f) W = 1.8f; + float ccx = s * 0.40f, ccy = s * 0.40f; /* ring centre */ + float hw = W * 0.5f; + float c45 = 0.70710678f; + /* handle segment, 45 deg toward the lower-right corner */ + float ax = ccx + c45 * (R - hw), ay = ccy + c45 * (R - hw); + float bx = ccx + c45 * (R + s * 0.42f), by = ccy + c45 * (R + s * 0.42f); + float seg2 = (bx - ax) * (bx - ax) + (by - ay) * (by - ay); + + for (int y = 0; y < s; y++) { + for (int x = 0; x < s; x++) { + int hits = 0; + for (int sy = 0; sy < 4; sy++) + for (int sx = 0; sx < 4; sx++) { + float px = x + (sx + 0.5f) / 4.0f; + float py = y + (sy + 0.5f) / 4.0f; + float dr = sqrtf((px - ccx) * (px - ccx) + + (py - ccy) * (py - ccy)); + int in = (dr <= R && dr >= R - W); + /* distance to the handle segment */ + float tt = seg2 > 0 ? + ((px - ax) * (bx - ax) + + (py - ay) * (by - ay)) / seg2 : 0; + if (tt < 0) tt = 0; + if (tt > 1) tt = 1; + float hxp = ax + (bx - ax) * tt; + float hyp = ay + (by - ay) * tt; + float dh = sqrtf((px - hxp) * (px - hxp) + + (py - hyp) * (py - hyp)); + if (in || dh <= hw) + hits++; + } + data[y * stride + x] = RGBA8(255, 255, 255, + hits * 255 / 16); + } + } + cache = t; + cs = s; + return cache; +} + +/* small clear (x) chip used on the right of the bar while a search is on */ +static void draw_clear_x(float cx, float cy, float s, unsigned int col) +{ + for (float t = -1.0f; t <= 1.0f; t += 0.2f) { + vita2d_draw_fill_circle(cx + t * s, cy + t * s, 1.5f, col); + vita2d_draw_fill_circle(cx + t * s, cy - t * s, 1.5f, col); + } +} + +/* pixel geometry of the search pill, shared by the drawer and the tap test */ +#define BAR_M 12.0f /* left/right margin */ +#define BAR_Y 8.0f /* top of the pill */ +#define BAR_H 40.0f /* pill height */ +#define BAR_R 12.0f /* corner radius */ +#define BAR_W ((float)SCREEN_W - 2 * BAR_M) +#define BAR_CLEAR_CX (BAR_M + BAR_W - 22.0f) /* centre of the clear chip */ + +static void draw_search_bar(void) +{ + unsigned int field = RGBA8(38, 38, 42, 255); + /* opaque strip so grid items scroll cleanly underneath the bar */ + vita2d_draw_rectangle(0, 0, SCREEN_W, SEARCH_H, RGBA8(16, 16, 16, 255)); + /* borderless field: an AA rounded-rect mask tinted to the field colour */ + vita2d_texture *mask = rounded_mask_tex((int)BAR_W, (int)BAR_H, BAR_R); + if (mask) + vita2d_draw_texture_tint(mask, BAR_M, BAR_Y, field); + else + vita2d_draw_rectangle(BAR_M, BAR_Y, BAR_W, BAR_H, field); + + int isz = 24; + vita2d_texture *icon = search_icon_tex(isz); + if (icon) + vita2d_draw_texture_tint(icon, BAR_M + 22 - isz / 2.0f, + BAR_Y + BAR_H / 2 - isz / 2.0f, + RGBA8(190, 190, 196, 255)); + + float tx = BAR_M + 44, ty = BAR_Y + BAR_H - 13; + if (g_search_active && g_search_query[0]) { + char q[64]; + snprintf(q, sizeof(q), "%.40s", g_search_query); + draw_text(tx, ty, RGBA8(235, 235, 240, 255), 0.95f, q); + vita2d_draw_fill_circle(BAR_CLEAR_CX, BAR_Y + BAR_H / 2, 11, + RGBA8(70, 70, 76, 255)); + draw_clear_x(BAR_CLEAR_CX, BAR_Y + BAR_H / 2, 4, + RGBA8(210, 210, 215, 255)); + } else { + draw_text(tx, ty, RGBA8(140, 140, 148, 255), 0.95f, + "Search your photos"); + } +} + +/* minimal UTF-8 <-> UTF-16 (BMP) for the on-screen keyboard buffers */ +static void utf8_to_utf16(const char *s, uint16_t *out, int outcap) +{ + int n = 0; + while (*s && n < outcap - 1) { + unsigned char c = (unsigned char)s[0]; + unsigned int cp; + if (c < 0x80) { + cp = c; s += 1; + } else if ((c >> 5) == 0x6 && (s[1] & 0xC0) == 0x80) { + cp = ((c & 0x1F) << 6) | (s[1] & 0x3F); s += 2; + } else if ((c >> 4) == 0xE && (s[1] & 0xC0) == 0x80 && + (s[2] & 0xC0) == 0x80) { + cp = ((c & 0x0F) << 12) | ((s[1] & 0x3F) << 6) | + (s[2] & 0x3F); s += 3; + } else { + cp = '?'; s += 1; + } + out[n++] = (uint16_t)(cp > 0xFFFF ? '?' : cp); + } + out[n] = 0; +} + +static void utf16_to_utf8(const uint16_t *s, char *out, int outcap) +{ + int n = 0; + for (; *s; s++) { + unsigned int cp = *s; + if (cp < 0x80) { + if (n + 1 >= outcap) break; + out[n++] = (char)cp; + } else if (cp < 0x800) { + if (n + 2 >= outcap) break; + out[n++] = (char)(0xC0 | (cp >> 6)); + out[n++] = (char)(0x80 | (cp & 0x3F)); + } else { + if (n + 3 >= outcap) break; + out[n++] = (char)(0xE0 | (cp >> 12)); + out[n++] = (char)(0x80 | ((cp >> 6) & 0x3F)); + out[n++] = (char)(0x80 | (cp & 0x3F)); + } + } + out[n] = '\0'; +} + +/* bring up the system on-screen keyboard (a common dialog). returns 1 and + * fills `out` (UTF-8) when the user confirms, 0 if they cancelled. blocks, + * driving the dialog each frame; the worker thread keeps running, which is + * fine since we touch no shared arrays here. */ +static int ime_input(const char *title, const char *initial, + char *out, size_t outsz) +{ + static uint16_t title16[SCE_IME_DIALOG_MAX_TITLE_LENGTH]; + static uint16_t init16[129]; + static uint16_t buf16[129]; + utf8_to_utf16(title, title16, SCE_IME_DIALOG_MAX_TITLE_LENGTH); + utf8_to_utf16(initial ? initial : "", init16, 129); + memset(buf16, 0, sizeof(buf16)); + + SceImeDialogParam p; + sceImeDialogParamInit(&p); + p.supportedLanguages = 0; /* allow all installed languages */ + p.languagesForced = 0; + p.type = SCE_IME_TYPE_DEFAULT; + p.option = SCE_IME_OPTION_NO_AUTO_CAPITALIZATION; + p.dialogMode = SCE_IME_DIALOG_DIALOG_MODE_WITH_CANCEL; + p.textBoxMode = SCE_IME_DIALOG_TEXTBOX_MODE_WITH_CLEAR; + p.title = title16; + p.maxTextLength = 128; + p.initialText = init16; + p.inputTextBuffer = buf16; + p.enterLabel = SCE_IME_ENTER_LABEL_SEARCH; + + if (sceImeDialogInit(&p) < 0) + return 0; + + int confirmed = 0; + for (;;) { + vita2d_start_drawing(); + vita2d_clear_screen(); + vita2d_end_drawing(); + vita2d_common_dialog_update(); + vita2d_swap_buffers(); + sceDisplayWaitVblankStart(); + + if (sceImeDialogGetStatus() != SCE_COMMON_DIALOG_STATUS_FINISHED) + continue; + SceImeDialogResult res; + memset(&res, 0, sizeof(res)); + sceImeDialogGetResult(&res); + if (res.button == SCE_IME_DIALOG_BUTTON_ENTER) { + utf16_to_utf8(buf16, out, (int)outsz); + confirmed = 1; + } + sceImeDialogTerm(); + break; + } + return confirmed; +} + +/* run an Immich smart (CLIP) search and switch the grid to its results. + * main thread only: it appends any newly-seen assets to the g_asset_* arrays + * (like fetch_page) and rebuilds g_disp from the result set. returns the + * number of distinct results shown (>=0), or -1 on a request/parse failure. */ +static int run_smart_search(const char *query) +{ + char esc[256]; /* JSON-escape the query into the request body */ + int e = 0; + for (const char *p = query; *p && e < (int)sizeof(esc) - 2; p++) { + unsigned char c = (unsigned char)*p; + if (c == '"' || c == '\\') { + esc[e++] = '\\'; + esc[e++] = (char)c; + } else if (c >= 0x20) { + esc[e++] = (char)c; + } else { + esc[e++] = ' '; + } + } + esc[e] = '\0'; + + char url[600]; + snprintf(url, sizeof(url), "%s/api/search/smart", g_server); + char body[320]; + snprintf(body, sizeof(body), "{\"query\":\"%s\"}", esc); + + membuf buf; + long code; + CURLcode res = http_request(url, body, &buf, &code, NULL, 0); + if (res != CURLE_OK || code < 200 || code >= 300) { + log_line("smart search: curl %d http %ld: %.160s", res, code, + buf.data ? buf.data : ""); + free(buf.data); + return -1; + } + + char err[160]; + int before = g_asset_count; + int added = parse_assets(buf.data, buf.size, err, sizeof(err)); + free(buf.data); + if (added < 0) { + log_line("smart search parse: %s", err); + return -1; + } + + /* the freshly-parsed tail [before, count) is the result set in server + * order. capture the ids before dedup compacts that tail around, then + * resolve each id to its surviving index in g_asset_*. */ + int raw = g_asset_count - before; + static char (*ids)[40]; + static int ids_cap; + if (raw > ids_cap) { + void *np = realloc(ids, sizeof(ids[0]) * raw); + if (!np) + return -1; + ids = np; + ids_cap = raw; + } + for (int i = 0; i < raw; i++) + memcpy(ids[i], g_asset_ids[before + i], sizeof(ids[0])); + + dedup_new_assets(before); + + if (!(g_search_cap = GROW(g_search_idx, g_search_cap, + raw > 0 ? raw : 1))) + return -1; + g_search_count = 0; + for (int i = 0; i < raw; i++) { + int idx = -1; + for (int j = 0; j < g_asset_count; j++) + if (!strcmp(g_asset_ids[j], ids[i])) { + idx = j; + break; + } + if (idx < 0) + continue; + int seen = 0; + for (int k = 0; k < g_search_count && !seen; k++) + seen = (g_search_idx[k] == idx); + if (!seen) + g_search_idx[g_search_count++] = idx; + } + + g_search_active = 1; + snprintf(g_search_query, sizeof(g_search_query), "%s", query); + rebuild_display(); + log_line("smart search '%s': %d result(s)", query, g_search_count); + return g_search_count; +} + static void draw_hud(const char *text) { vita2d_draw_rectangle(0, SCREEN_H - 32, SCREEN_W, 32, RGBA8(0, 0, 0, 180)); @@ -3158,6 +3551,15 @@ int main(void) g_font = vita2d_load_default_pgf(); g_ttf = vita2d_load_font_file("app0:font.ttf"); + /* on-screen keyboard (smart search). the IME runs as a common dialog; + * the config tells it the system language + enter/cancel button map */ + sceSysmoduleLoadModule(SCE_SYSMODULE_IME); + { + SceCommonDialogConfigParam cfg; + sceCommonDialogConfigParamInit(&cfg); + sceCommonDialogSetConfigParam(&cfg); + } + /* analog mode so the left stick reports lx/ly for detail-view panning */ sceCtrlSetSamplingMode(SCE_CTRL_MODE_ANALOG); /* front touch: drag scrolls the grid, a tap opens the item */ @@ -3271,6 +3673,7 @@ int main(void) * changes photo, drag pans when zoomed) */ int touch_active = 0; /* finger currently down */ int touch_dragged = 0; /* moved past the tap threshold */ + int touch_on_bar = 0; /* contact began on the search bar */ float touch_x = 0, touch_y = 0; /* last position, screen px */ float touch_start_x = 0, touch_start_y = 0, touch_start_scroll = 0; float touch_panx0 = 0, touch_pany0 = 0; /* pan at touch start (zoomed) */ @@ -3335,6 +3738,13 @@ int main(void) nav |= pad.buttons & dirs; if (mode == MODE_GRID) { + /* search bar: TRIANGLE opens the keyboard, CIRCLE (or + * the bar's clear chip) drops back to the timeline. a + * tap on the bar sets these too; handled after touch. */ + int do_search_open = (pressed & SCE_CTRL_TRIANGLE) != 0; + int do_search_clear = g_search_active && + (pressed & SCE_CTRL_CIRCLE) != 0; + if (nav & SCE_CTRL_RIGHT) sel++; if (nav & SCE_CTRL_LEFT) @@ -3354,8 +3764,9 @@ int main(void) +1 : -1; /* jumping down: the next month may simply not * be fetched yet — pull pages until a new - * month shows up (or the library ends) */ - if (dir > 0) { + * month shows up (or the library ends). search + * results aren't paginated, so skip the pull. */ + if (dir > 0 && !g_search_active) { int guard = 0; while (g_next_page > 0 && guard++ < 10 && !strncmp(disp_date(month_jump(sel, +1)), @@ -3367,10 +3778,10 @@ int main(void) } } sel = month_jump(sel, dir); - /* put the jumped-to month at the top of the - * screen, header band included */ + /* put the jumped-to month just below the pinned + * search bar, header band included */ if (sel >= 0 && sel < g_disp_count) - target = g_item_y[sel] - HEADER_H; + target = g_item_y[sel] - HEADER_H - SEARCH_H; } /* Track how long the selection has held still. A held @@ -3392,7 +3803,7 @@ int main(void) } int scrolling_fast = (grid_settle < 12); - if (pressed & SCE_CTRL_CROSS) { + if ((pressed & SCE_CTRL_CROSS) && g_disp_count > 0) { /* photos and videos both open the detail * view; a video shows its poster with the * play button there */ @@ -3407,7 +3818,8 @@ int main(void) * end (gated on !scrolling_fast, see above). no loading * screen: the grid stays up, the HUD's "+" already says * more is coming, and the fetch only blocks briefly */ - if (!scrolling_fast && g_next_page > 0 && + if (!scrolling_fast && !g_search_active && + g_next_page > 0 && sel >= g_disp_count - COLS * 4) { if (fetch_page(0) > 0) rebuild_keep_view(&sel, &scroll, @@ -3419,7 +3831,7 @@ int main(void) * settled, keeping the selection on the same photo * across the relayout */ uint64_t pnow = sceKernelGetProcessTimeWide(); - if (!scrolling_fast && !touch_active && + if (!scrolling_fast && !touch_active && !g_search_active && pnow - last_poll > 30ULL * 1000 * 1000) { last_poll = pnow; if (check_new_assets() > 0) @@ -3442,10 +3854,14 @@ int main(void) if (!touch_active) { touch_active = 1; touch_dragged = 0; + touch_start_x = tx; touch_start_y = ty; touch_start_scroll = scroll; touch_vel = 0; - } else { + /* a contact on the bar opens the + * keyboard instead of scrolling */ + touch_on_bar = (ty < (float)SEARCH_H); + } else if (!touch_on_bar) { float dy = ty - touch_start_y; if (!touch_dragged && (dy > 14.0f || dy < -14.0f)) @@ -3460,7 +3876,8 @@ int main(void) scroll = target = ns; /* keep the selection inside * the dragged viewport */ - int c = item_near(ns + SCREEN_H / 2); + int c = item_near(ns + + (SEARCH_H + SCREEN_H) / 2); if (c >= 0) sel = c; } @@ -3469,7 +3886,17 @@ int main(void) touch_y = ty; } else if (touch_active) { touch_active = 0; - if (!touch_dragged) { + if (touch_on_bar) { + /* tap on the search bar: clear + * chip while a search is on, else + * open the keyboard */ + if (g_search_active && + touch_x > BAR_CLEAR_CX - 16) + do_search_clear = 1; + else + do_search_open = 1; + touch_on_bar = 0; + } else if (!touch_dragged) { /* tap: open the item under it * (videos show their poster + * play button in the detail) */ @@ -3494,11 +3921,68 @@ int main(void) target = 0; if (target > maxs) target = maxs; - int c = item_near(target + SCREEN_H / 2); + int c = item_near(target + + (SEARCH_H + SCREEN_H) / 2); if (c >= 0) sel = c; } } + + /* act on a search request from TRIANGLE/CIRCLE + * or a tap on the bar. both block this frame + * (keyboard / request), so restart the loop. */ + if (do_search_clear) { + g_search_active = 0; + g_search_query[0] = '\0'; + rebuild_display(); + sel = 0; + scroll = target = 0; + detail_idx = -1; + grid_last_sel = -1; + for (int k = 0; k < 2; k++) { + if (pf_tex[k]) { + vita2d_wait_rendering_done(); + vita2d_free_texture(pf_tex[k]); + pf_tex[k] = NULL; + } + pf_d[k] = -1; + } + continue; + } + if (do_search_open) { + char q[128] = ""; + int ok = ime_input("Search your photos", + g_search_active ? g_search_query : "", + q, sizeof(q)); + if (ok && q[0]) { + show_status("Searching \"%s\"...", q); + int nres = run_smart_search(q); + if (nres >= 0) { + sel = 0; + scroll = target = 0; + detail_idx = -1; + grid_last_sel = -1; + for (int k = 0; k < 2; k++) { + if (pf_tex[k]) { + vita2d_wait_rendering_done(); + vita2d_free_texture(pf_tex[k]); + pf_tex[k] = NULL; + } + pf_d[k] = -1; + } + } else { + show_blocking_error( + "Search failed", + "Smart search may be " + "disabled on the server " + "(see log.txt)."); + } + } + /* swallow buttons held during the dialog */ + prev_buttons = 0xFFFFFFFF; + touch_active = touch_on_bar = 0; + continue; + } } /* scroll follows the selection, using the precomputed @@ -3506,8 +3990,9 @@ int main(void) * moving up, reveal the header band above the item. */ float sel_y = (sel >= 0 && sel < g_disp_count) ? g_item_y[sel] : 0.0f; - if (sel_y - HEADER_H < target) - target = sel_y - HEADER_H; + /* reveal the header band below the pinned search bar */ + if (sel_y - HEADER_H - SEARCH_H < target) + target = sel_y - HEADER_H - SEARCH_H; float sel_h = (sel >= 0 && sel < g_disp_count) ? g_item_h[sel] : ROW_H; if (sel_y + sel_h > target + SCREEN_H) @@ -3638,11 +4123,28 @@ int main(void) g_item_h[i] - 4); } - char hud[160]; - snprintf(hud, sizeof(hud), - "%d / %d%s %.10s X view SELECT sync START exit", - sel + 1, g_disp_count, g_next_page > 0 ? "+" : "", - g_disp_count > 0 ? disp_date(sel) : ""); + if (g_search_active && g_disp_count == 0) + draw_centered(SCREEN_H / 2, + RGBA8(160, 160, 165, 255), + "No results"); + + /* pinned Immich-style search bar, on top of the grid */ + draw_search_bar(); + + char hud[200]; + if (g_search_active) + snprintf(hud, sizeof(hud), + "%d / %d results /\\ edit O clear " + "X view START exit", + g_disp_count > 0 ? sel + 1 : 0, + g_disp_count); + else + snprintf(hud, sizeof(hud), + "%d / %d%s %.10s X view /\\ search " + "SELECT sync START exit", + sel + 1, g_disp_count, + g_next_page > 0 ? "+" : "", + g_disp_count > 0 ? disp_date(sel) : ""); draw_hud(hud); vita2d_end_drawing();