diff --git a/locales/de.json b/locales/de.json index dbd70225..4040bb6e 100644 --- a/locales/de.json +++ b/locales/de.json @@ -264,6 +264,7 @@ }, "filter": { "title": "Modelle filtern", + "countTitle": "[TODO: Translate] {name} — {count} models", "presets": "Voreinstellungen", "savePreset": "Aktive Filter als neue Voreinstellung speichern.", "savePresetDisabledActive": "Speichern nicht möglich: Eine Voreinstellung ist bereits aktiv. Ändern Sie die Filter, um eine neue Voreinstellung zu speichern", @@ -1097,6 +1098,9 @@ }, "recipes": { "title": "LoRA-Rezepte", + "filter": { + "countTitle": "[TODO: Translate] {name} — {count} recipes" + }, "actions": { "sendCheckpoint": "Send to ComfyUI", "sendRecipe": "Send to ComfyUI", diff --git a/locales/en.json b/locales/en.json index f63ee14b..b80d3229 100644 --- a/locales/en.json +++ b/locales/en.json @@ -264,6 +264,7 @@ }, "filter": { "title": "Filter Models", + "countTitle": "{name} — {count} models", "presets": "Presets", "savePreset": "Save current active filters as a new preset.", "savePresetDisabledActive": "Cannot save: A preset is already active. Modify filters to save new preset.", @@ -1097,6 +1098,9 @@ }, "recipes": { "title": "LoRA Recipes", + "filter": { + "countTitle": "{name} — {count} recipes" + }, "actions": { "sendCheckpoint": "Send to ComfyUI", "sendRecipe": "Send to ComfyUI", diff --git a/locales/es.json b/locales/es.json index fa560481..611e0e3a 100644 --- a/locales/es.json +++ b/locales/es.json @@ -264,6 +264,7 @@ }, "filter": { "title": "Filtrar modelos", + "countTitle": "[TODO: Translate] {name} — {count} models", "presets": "Preajustes", "savePreset": "Guardar filtros activos como nuevo preajuste.", "savePresetDisabledActive": "No se puede guardar: Ya hay un preajuste activo. Modifique los filtros para guardar un nuevo preajuste", @@ -1097,6 +1098,9 @@ }, "recipes": { "title": "Recetas de LoRA", + "filter": { + "countTitle": "[TODO: Translate] {name} — {count} recipes" + }, "actions": { "sendCheckpoint": "Enviar a ComfyUI", "sendRecipe": "Enviar a ComfyUI", diff --git a/locales/fr.json b/locales/fr.json index 8f0bc176..c5989fef 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -264,6 +264,7 @@ }, "filter": { "title": "Filtrer les modèles", + "countTitle": "[TODO: Translate] {name} — {count} models", "presets": "Préréglages", "savePreset": "Enregistrer les filtres actifs comme nouveau préréglage.", "savePresetDisabledActive": "Impossible d'enregistrer : Un préréglage est déjà actif. Modifiez les filtres pour enregistrer un nouveau préréglage", @@ -1097,6 +1098,9 @@ }, "recipes": { "title": "LoRA Recipes", + "filter": { + "countTitle": "[TODO: Translate] {name} — {count} recipes" + }, "actions": { "sendCheckpoint": "Envoyer vers ComfyUI", "sendRecipe": "Envoyer vers ComfyUI", diff --git a/locales/he.json b/locales/he.json index 95da3926..a4efdca5 100644 --- a/locales/he.json +++ b/locales/he.json @@ -264,6 +264,7 @@ }, "filter": { "title": "סנן מודלים", + "countTitle": "[TODO: Translate] {name} — {count} models", "presets": "קביעות מראש", "savePreset": "שמור מסננים פעילים כקביעה מראש חדשה.", "savePresetDisabledActive": "לא ניתן לשמור: קביעה מראש כבר פעילה. שנה מסננים כדי לשמור קביעה מראש חדשה", @@ -1097,6 +1098,9 @@ }, "recipes": { "title": "מתכוני LoRA", + "filter": { + "countTitle": "[TODO: Translate] {name} — {count} recipes" + }, "actions": { "sendCheckpoint": "שלח ל-ComfyUI", "sendRecipe": "שלח ל-ComfyUI", diff --git a/locales/ja.json b/locales/ja.json index 9749ce3f..a14afc9f 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -264,6 +264,7 @@ }, "filter": { "title": "モデルをフィルタ", + "countTitle": "[TODO: Translate] {name} — {count} models", "presets": "プリセット", "savePreset": "現在のアクティブフィルタを新しいプリセットとして保存。", "savePresetDisabledActive": "保存できません:プリセットがすでにアクティブです。フィルタを変更して新しいプリセットを保存してください", @@ -1097,6 +1098,9 @@ }, "recipes": { "title": "LoRAレシピ", + "filter": { + "countTitle": "[TODO: Translate] {name} — {count} recipes" + }, "actions": { "sendCheckpoint": "ComfyUIへ送信", "sendRecipe": "ComfyUIへ送信", diff --git a/locales/ko.json b/locales/ko.json index 4fb2318d..874bd654 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -264,6 +264,7 @@ }, "filter": { "title": "모델 필터", + "countTitle": "[TODO: Translate] {name} — {count} models", "presets": "프리셋", "savePreset": "현재 활성 필터를 새 프리셋으로 저장.", "savePresetDisabledActive": "저장할 수 없음: 프리셋이 이미 활성화되어 있습니다. 필터를 수정한 후 새 프리셋을 저장하세요", @@ -1097,6 +1098,9 @@ }, "recipes": { "title": "LoRA 레시피", + "filter": { + "countTitle": "[TODO: Translate] {name} — {count} recipes" + }, "actions": { "sendCheckpoint": "ComfyUI로 보내기", "sendRecipe": "ComfyUI로 보내기", diff --git a/locales/ru.json b/locales/ru.json index 7800877c..bfa9e920 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -264,6 +264,7 @@ }, "filter": { "title": "Фильтр моделей", + "countTitle": "[TODO: Translate] {name} — {count} models", "presets": "Пресеты", "savePreset": "Сохранить текущие активные фильтры как новый пресет.", "savePresetDisabledActive": "Невозможно сохранить: Пресет уже активен. Измените фильтры, чтобы сохранить новый пресет", @@ -1097,6 +1098,9 @@ }, "recipes": { "title": "Рецепты LoRA", + "filter": { + "countTitle": "[TODO: Translate] {name} — {count} recipes" + }, "actions": { "sendCheckpoint": "Отправить в ComfyUI", "sendRecipe": "Отправить в ComfyUI", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 9fe3df0e..f3157427 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -264,6 +264,7 @@ }, "filter": { "title": "筛选模型", + "countTitle": "[TODO: Translate] {name} — {count} models", "presets": "预设", "savePreset": "将当前激活的筛选器保存为新预设。", "savePresetDisabledActive": "无法保存:已有预设处于激活状态。修改筛选器后可保存新预设", @@ -1097,6 +1098,9 @@ }, "recipes": { "title": "LoRA 配方", + "filter": { + "countTitle": "[TODO: Translate] {name} — {count} recipes" + }, "actions": { "sendCheckpoint": "发送到 ComfyUI", "sendRecipe": "发送到 ComfyUI", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 1ada34a9..aa83a5a8 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -264,6 +264,7 @@ }, "filter": { "title": "篩選模型", + "countTitle": "[TODO: Translate] {name} — {count} models", "presets": "預設", "savePreset": "將目前啟用的篩選器儲存為新預設。", "savePresetDisabledActive": "無法儲存:已有預設處於啟用狀態。修改篩選器後可儲存新預設", @@ -1097,6 +1098,9 @@ }, "recipes": { "title": "LoRA 配方", + "filter": { + "countTitle": "[TODO: Translate] {name} — {count} recipes" + }, "actions": { "sendCheckpoint": "傳送到 ComfyUI", "sendRecipe": "傳送到 ComfyUI", diff --git a/static/css/components/search-filter.css b/static/css/components/search-filter.css index 91925fb3..422ee526 100644 --- a/static/css/components/search-filter.css +++ b/static/css/components/search-filter.css @@ -295,12 +295,44 @@ min-width: 60px; } +/* Chips that carry a count get a stable two-slot layout: the label shrinks and + ellipsizes, the count keeps its own slot. Without it a long tag name and a + 5+ digit count (large libraries) fight over the chip and the count pill can + be pushed past the chip's border. */ +.filter-tag:has(.tag-count) { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + min-width: 60px; + max-width: 100%; +} + +.filter-tag:has(.tag-count) .tag-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.filter-tag:has(.tag-count) .tag-count { + margin-left: 0; + flex-shrink: 0; +} + .tag-count { background: rgba(0, 0, 0, 0.1); padding: 1px 6px; border-radius: 10px; font-size: 0.8em; margin-left: 4px; + /* Counts reach 5+ digits in large libraries: tabular digits keep the pill + from resizing as the data refreshes, right alignment pins the digits to + the chip edge, and the min-width keeps 1-3 digit counts from producing a + cramped pill next to a 4-digit one. */ + font-variant-numeric: tabular-nums; + text-align: right; + min-width: 2.5ch; } [data-theme="dark"] .tag-count { diff --git a/static/css/layout.css b/static/css/layout.css index 7de45c3f..b86779ae 100644 --- a/static/css/layout.css +++ b/static/css/layout.css @@ -750,16 +750,35 @@ body.sticky-controls .sticky-topbar { opacity: 0.6; } +/* Label and count as two slots instead of label + floated count: the label + column absorbs the row's spare width (so counts still line up on the right) + and ellipsizes rather than pushing the count out of the menu. */ +.scan-root-item { + display: flex; + align-items: center; +} + +.scan-root-item i { + flex-shrink: 0; +} + .scan-root-label { + flex: 1; + min-width: 0; margin-right: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .scan-root-count, .scan-root-offline { - float: right; margin-left: 16px; font-size: 0.9em; opacity: 0.7; + flex-shrink: 0; + font-variant-numeric: tabular-nums; + white-space: nowrap; } .scan-root-item.is-offline .scan-root-offline { diff --git a/static/js/components/alphabet/AlphabetBar.js b/static/js/components/alphabet/AlphabetBar.js index 2d358156..f81db912 100644 --- a/static/js/components/alphabet/AlphabetBar.js +++ b/static/js/components/alphabet/AlphabetBar.js @@ -2,6 +2,7 @@ import { getCurrentPageState } from '../../state/index.js'; import { getStorageItem, setStorageItem } from '../../utils/storageHelpers.js'; import { resetAndReload } from '../../api/modelApiFactory.js'; +import { formatCount, formatCountExact } from '../../utils/formatters.js'; /** * AlphabetBar class - Handles the alphabet filtering UI and interactions @@ -77,7 +78,7 @@ export class AlphabetBar { // Update the title attribute for tooltip display if (count > 0) { - chip.title = `${letter}: ${count} LoRAs`; + chip.title = `${letter}: ${formatCountExact(count)} LoRAs`; chip.classList.remove('disabled'); } else { chip.title = `${letter}: No LoRAs`; @@ -87,7 +88,7 @@ export class AlphabetBar { // Keep the count span for backward compatibility const countSpan = chip.querySelector('.count'); if (countSpan) { - countSpan.textContent = ` (${count})`; + countSpan.textContent = ` (${formatCount(count)})`; } }); } diff --git a/static/js/components/controls/ModelTypeDropdown.js b/static/js/components/controls/ModelTypeDropdown.js index 39b8d027..81ecbf70 100644 --- a/static/js/components/controls/ModelTypeDropdown.js +++ b/static/js/components/controls/ModelTypeDropdown.js @@ -15,6 +15,7 @@ import { MODEL_TYPE_DISPLAY_NAMES } from '../../utils/constants.js'; import { translate } from '../../utils/i18nHelpers.js'; +import { formatCount, formatCountTitle } from '../../utils/formatters.js'; const GROUP_ID = 'modelTypeDropdown'; const ALL_TYPES_VALUE = ''; @@ -89,7 +90,13 @@ export async function initModelTypeDropdown(pageType) { item.setAttribute('role', 'option'); item.tabIndex = -1; item.dataset.value = type; - item.textContent = `${MODEL_TYPE_DISPLAY_NAMES[type]} (${count})`; + item.textContent = `${MODEL_TYPE_DISPLAY_NAMES[type]} (${formatCount(count)})`; + // Same rule as the filter chips: the tooltip carries the exact count + // at every magnitude, so hovering behaves the same whether or not + // the menu had to abbreviate. It also replaces the tooltip this + // option would otherwise inherit from the group, which describes the + // control rather than this entry. + item.title = formatCountTitle(MODEL_TYPE_DISPLAY_NAMES[type], count); fragment.appendChild(item); }); diff --git a/static/js/components/controls/ScanScopeMenu.js b/static/js/components/controls/ScanScopeMenu.js index 8b03cf94..f4ea5686 100644 --- a/static/js/components/controls/ScanScopeMenu.js +++ b/static/js/components/controls/ScanScopeMenu.js @@ -1,5 +1,6 @@ // ScanScopeMenu.js - per-root entries of the Refresh dropdown (issue #1108) import { translate } from '../../utils/i18nHelpers.js'; +import { formatCount, formatCountExact } from '../../utils/formatters.js'; /** * Render one row per configured model root. @@ -39,13 +40,23 @@ export function renderScanScopeMenu(menu, details = []) { const meta = document.createElement('span'); if (detail.reachable) { - const count = Number(detail.models || 0).toLocaleString(); + const count = Number(detail.models || 0); + // The visible count is abbreviated at large magnitudes, like the + // filter chips; the row tooltip always names the path and the exact + // count, so its shape does not change with the library size. + const exact = formatCountExact(count); + const shown = formatCount(count); meta.className = 'scan-root-count'; meta.textContent = translate( 'loras.controls.refresh.rootModels', - { count }, - `${count} models` + { count: shown }, + `${shown} models` ); + item.title = `${detail.path}\n${translate( + 'loras.controls.refresh.rootModels', + { count: exact }, + `${exact} models` + )}`; } else { meta.className = 'scan-root-offline'; meta.textContent = translate('loras.controls.refresh.rootOffline', {}, 'Offline'); diff --git a/static/js/managers/FilterManager.js b/static/js/managers/FilterManager.js index 356b77d0..19f9a8ae 100644 --- a/static/js/managers/FilterManager.js +++ b/static/js/managers/FilterManager.js @@ -5,12 +5,49 @@ import { getApiEndpoints } from '../api/apiConfig.js'; import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js'; import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js'; import { translate } from '../utils/i18nHelpers.js'; +import { formatCount, formatCountTitle } from '../utils/formatters.js'; import { FilterPresetManager, EMPTY_WILDCARD_MARKER } from './FilterPresetManager.js'; // LoRA availability statuses available on the recipes page. No statuses // selected (the default) means no filtering. const LORA_AVAILABILITY_STATUSES = ['ready', 'missing', 'deleted']; +/** + * Render a filter chip's label and its optional count. + * + * The name gets its own span so CSS can ellipsize it instead of letting a long + * name squeeze the count pill out of the chip; the chip tooltip repeats the + * full name next to the exact count, which stays readable there even when the + * number on screen is abbreviated. Both parts are set through textContent, so + * a tag name is never parsed as HTML. + * + * @param {HTMLElement} chip - The chip element to fill + * @param {string} name - Label text + * @param {number|null} [count] - Entry count; omit to render a count-less chip + * @param {string} [titleKey] - Translation key for the count sentence + */ +function renderChipContent(chip, name, count, titleKey) { + const nameEl = document.createElement('span'); + nameEl.className = 'tag-name'; + nameEl.textContent = name; + chip.appendChild(nameEl); + + if (count === null || count === undefined) { + return; + } + + const countEl = document.createElement('span'); + countEl.className = 'tag-count'; + countEl.textContent = formatCount(count); + chip.appendChild(countEl); + + const title = formatCountTitle(name, count, titleKey); + chip.title = title; + // title is unreliable for assistive tech, and the visible name may be + // ellipsized, so the accessible name carries the full sentence too. + chip.setAttribute('aria-label', title); +} + export class FilterManager { constructor(options = {}) { this.options = { @@ -18,6 +55,11 @@ export class FilterManager { }; this.currentPage = options.page || document.body.dataset.page || 'loras'; + // The filter chips count models on every page except Recipes, where the + // same sections count recipes. + this.countTitleKey = this.currentPage === 'recipes' + ? 'recipes.filter.countTitle' + : 'header.filter.countTitle'; const pageState = getCurrentPageState(); this.filters = this.initializeFilters(pageState ? pageState.filters : undefined); @@ -309,11 +351,7 @@ export class FilterManager { tagEl.dataset.tag = tagName; // Show count only if it's > 0 (known count) - if (tag.count > 0) { - tagEl.innerHTML = `${tagName} ${tag.count}`; - } else { - tagEl.textContent = tagName; - } + renderChipContent(tagEl, tagName, tag.count > 0 ? tag.count : null, this.countTitleKey); // Add click handler to cycle through tri-state filter and automatically apply tagEl.addEventListener('click', async () => { @@ -543,7 +581,7 @@ export class FilterManager { // bucket shows "Unknown" but filters via a dedicated marker). const filterValue = model.value ?? model.name; tag.dataset.baseModel = filterValue; - tag.innerHTML = `${model.name} ${model.count}`; + renderChipContent(tag, model.name, model.count, this.countTitleKey); tag.addEventListener('click', async () => { tag.classList.toggle('active'); @@ -614,7 +652,7 @@ export class FilterManager { const tag = document.createElement('div'); tag.className = 'filter-tag model-type-tag'; tag.dataset.modelType = type; - tag.innerHTML = `${MODEL_TYPE_DISPLAY_NAMES[type]} ${count}`; + renderChipContent(tag, MODEL_TYPE_DISPLAY_NAMES[type], count, this.countTitleKey); if (this.filters.modelTypes.includes(type)) { tag.classList.add('active'); diff --git a/static/js/utils/formatters.js b/static/js/utils/formatters.js index 0de62827..df1f8253 100644 --- a/static/js/utils/formatters.js +++ b/static/js/utils/formatters.js @@ -1,3 +1,81 @@ +import { i18n } from '../i18n/index.js'; +import { translate } from './i18nHelpers.js'; + +/** + * Magnitude from which counts are abbreviated in dense UI (filter chips, + * dropdown rows). Below it the count is shown exact, so ordinary libraries + * look exactly like before; above it a five digit number would otherwise + * become the widest and loudest element of the row it sits in. The exact value + * stays available through `formatCountExact` for tooltips and aria-labels. + */ +const COUNT_COMPACT_FROM = 10000; + +/** + * Format an entry count (models, recipes, tags) for dense UI. + * + * Abbreviated to ~3 significant digits above `compactFrom`, so a five digit + * count does not become the widest element of the row it sits in. The + * abbreviation is left to Intl, which follows the UI language ("19.9K", + * "1.99万", "19,9 k"). + * + * Locales without a compact unit for the magnitude are detected and fall back + * to the exact, grouped count: German has no unit below a million, so its + * "compact" form is a *rounded* plain number ("18.500" for 18,475) that reads + * as exact but is not, and Russian's "19,9 тыс." is wider than "18 475". An + * abbreviation is only used when the locale actually produced one and it is + * shorter than the exact count. + * + * @param {number} value - The count + * @param {Object} [options] + * @param {number} [options.compactFrom] - Magnitude to start abbreviating at + * @returns {string} Formatted count (e.g. "42", "5,210", "19.9K") + */ +export function formatCount(value, { compactFrom = COUNT_COMPACT_FROM } = {}) { + const count = Number(value) || 0; + const exact = i18n.formatNumber(count); + if (count < compactFrom) { + return exact; + } + + const compact = i18n.formatNumber(count, { + notation: 'compact', + maximumSignificantDigits: 3, + }); + const roundedPlain = i18n.formatNumber(Number(count.toPrecision(3))); + + return compact !== roundedPlain && compact.length < exact.length ? compact : exact; +} + +/** + * Format an entry count in full, for tooltips and aria-labels that must not + * lose precision to `formatCount`'s abbreviation. + * + * @param {number} value - The count + * @returns {string} Exact, locale-grouped count (e.g. "19,876") + */ +export function formatCountExact(value) { + return i18n.formatNumber(Number(value) || 0); +} + +/** + * Hover text for a counted chip or dropdown row: the full name plus the exact + * count. + * + * It is set unconditionally rather than only when the rendered count was + * abbreviated, for two reasons: the name is what CSS ellipsizes when a chip is + * narrow, so the tooltip is the only place the full label can be read; and an + * affordance that appears at some magnitudes and not others is unpredictable. + * + * @param {string} name - Label the count belongs to + * @param {number} value - The count + * @param {string} [key] - Translation key for the sentence + * @returns {string} e.g. "character — 18,475 models" + */ +export function formatCountTitle(name, value, key = 'header.filter.countTitle') { + const exact = formatCountExact(value); + return translate(key, { name, count: exact }, `${name} — ${exact} models`); +} + /** * Format a file size in bytes to a human-readable string * @param {number} bytes - The size in bytes diff --git a/templates/components/controls.html b/templates/components/controls.html index b065b61a..8bc8d121 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -65,8 +65,10 @@ {% if page_id in ['loras', 'checkpoints', 'other'] %} -