// Copyright Epic Games, Inc. All Rights Reserved. "use strict"; import { Component } from "./component.js" import { Friendly } from "../util/friendly.js" //////////////////////////////////////////////////////////////////////////////// export function flash_highlight(element) { if (!element) { return; } element.classList.add("pager-search-highlight"); setTimeout(() => { element.classList.remove("pager-search-highlight"); }, 1500); } //////////////////////////////////////////////////////////////////////////////// class Widget extends Component { } //////////////////////////////////////////////////////////////////////////////// class TableCell extends Widget { constructor(element, row) { super(element); this._row = row; } get_table() { return this.get_row().get_table(); } get_row() { return this._row; } } //////////////////////////////////////////////////////////////////////////////// class TableRow extends Widget { constructor(element, table, index, cells) { super(element); this._table = table; this._index = index; this._cells = cells; } *[Symbol.iterator]() { for (var cell of this._cells) yield cell; } get_table() { return this._table; } get_index() { return this._index; } get_cell(index) { return this._cells.at(index); } } //////////////////////////////////////////////////////////////////////////////// export class Table extends Widget { static Flag_EvenSpacing = 1 << 0; static Flag_PackRight = 1 << 1; static Flag_BiasLeft = 1 << 2; static Flag_FitLeft = 1 << 3; static Flag_Sortable = 1 << 4; static Flag_AlignNumeric = 1 << 5; constructor(parent, column_names, flags=Table.Flag_EvenSpacing, index_base=0) { var root = parent.tag().classify("zen_table"); super(root); const column_width = 0 | (100 / column_names.length); var column_style; if (flags & Table.Flag_FitLeft) column_style = "max-content"; else if (column_names.length == 1) column_style = "1fr"; else if (flags & Table.Flag_BiasLeft) column_style = `minmax(${column_width * 2}%, 1fr)`; else column_style = `minmax(${column_width}%, 1fr)`; for (var i = 1; i < column_names.length; ++i) { const style = (flags & Table.Flag_PackRight) ? " auto" : " 1fr"; column_style += style; } if (index_base >= 0) { column_names = ["#", ...column_names]; column_style = "max-content " + column_style; } root.style("gridTemplateColumns", column_style); this._header_row = this._add_row(column_names, false); this._index = index_base; this._num_columns = column_names.length; this._rows = []; this._flags = flags; this._sort_column = -1; this._sort_ascending = true; if (flags & Table.Flag_Sortable) { this._init_sortable(); } } _init_sortable() { const header_elem = this._element.firstElementChild; if (!header_elem) { return; } const cells = header_elem.children; for (let i = 0; i < cells.length; i++) { const cell = cells[i]; cell.style.cursor = "pointer"; cell.style.userSelect = "none"; cell.addEventListener("click", () => this._sort_by(i)); } } _sort_by(column_index) { if (this._sort_column === column_index) { this._sort_ascending = !this._sort_ascending; } else { this._sort_column = column_index; this._sort_ascending = true; } // Update header indicators const header_elem = this._element.firstElementChild; for (const cell of header_elem.children) { const text = cell.textContent.replace(/ [▲▼]$/, ""); cell.textContent = text; } const active_cell = header_elem.children[column_index]; active_cell.textContent += this._sort_ascending ? " ▲" : " ▼"; // Sort rows by comparing cell text content const dir = this._sort_ascending ? 1 : -1; const unit_multipliers = { "B": 1, "KiB": 1024, "MiB": 1048576, "GiB": 1073741824, "TiB": 1099511627776, "PiB": 1125899906842624, "EiB": 1152921504606846976 }; const parse_sortable = (text) => { // Try byte units first (e.g. "1,234 KiB", "1.5 GiB") const byte_match = text.match(/^([\d,.]+)\s*(B|[KMGTPE]iB)/); if (byte_match) { const num = parseFloat(byte_match[1].replace(/,/g, "")); const mult = unit_multipliers[byte_match[2]] || 1; return num * mult; } // Try percentage (e.g. "95.5%") const pct_match = text.match(/^([\d,.]+)%/); if (pct_match) { return parseFloat(pct_match[1].replace(/,/g, "")); } // Try plain number (possibly with commas/separators) const num = parseFloat(text.replace(/,/g, "")); if (!isNaN(num)) { return num; } return null; }; this._rows.sort((a, b) => { const aElem = a.inner().children[column_index]; const bElem = b.inner().children[column_index]; const aText = aElem ? aElem.textContent : ""; const bText = bElem ? bElem.textContent : ""; const aNum = parse_sortable(aText); const bNum = parse_sortable(bText); if (aNum !== null && bNum !== null) { return (aNum - bNum) * dir; } return aText.localeCompare(bText) * dir; }); // Re-order DOM elements for (const row of this._rows) { this._element.appendChild(row.inner()); } } *[Symbol.iterator]() { for (var row of this._rows) yield row; } get_row(index) { return this._rows.at(index); } _add_row(cells, indexed=true) { var index = -1; if (indexed && this._index >= 0) { index = this._index++; cells = [index, ...cells]; } cells = cells.slice(0, this._num_columns); while (cells.length < this._num_columns) cells.push(""); var ret = []; var row = this.tag(); row = new TableRow(row, this, index, ret); for (const cell of cells) { var leaf = row.tag().text(cell); ret.push(new TableCell(leaf, row)); } if ((this._flags & Table.Flag_AlignNumeric) && indexed) { for (const c of ret) { const t = c.inner().textContent; if (t && /^\d/.test(t)) { c.style("textAlign", "right"); } } } if (this._index >= 0) ret.shift(); return row; } add_row(...args) { var row = this._add_row(args); this._rows.push(row); if (this._flags & Table.Flag_AlignNumeric) { this._align_header(); } return row; } _align_header() { if (this._rows.length === 0) { return; } const header_elem = this._element.firstElementChild; const header_cells = header_elem.children; // A column is numeric if any data row has right-aligned content in it. for (const row of this._rows) { const data_cells = row.inner().children; for (let i = 0; i < data_cells.length && i < header_cells.length; i++) { if (data_cells[i].style.textAlign === "right") { header_cells[i].style.textAlign = "right"; } } } } clear(index=0) { const elem = this._element; elem.replaceChildren(elem.firstElementChild); this._index = (this._index >= 0) ? index : -1; this._rows = []; } } //////////////////////////////////////////////////////////////////////////////// export class PropTable extends Table { constructor(parent) { super(parent, ["prop", "value"], Table.Flag_FitLeft, -1); this.classify("zen_proptable"); } add_property(key, value) { return this.add_row(key, value); } add_object(object, friendly=false, prec=2) { const impl = (node, prefix="") => { for (const key in node) { var value = node[key]; if (value instanceof Object && (value.constructor.name == "Object" || value.constructor.name == "Array")) { impl(value, prefix + key + "."); continue; } if (friendly && ((typeof value == "number") || (typeof value == "bigint"))) { if (key.indexOf("memory") >= 0) value = Friendly.kib(value); else if (key.indexOf("disk") >= 0) value = Friendly.kib(value); else if (value > 100000) value = Friendly.k(value); else if (value % 1) value = Friendly.sep(value, 3); else value = Friendly.sep(value, 0); } this.add_property(prefix + key, value); } }; return impl(object); } filter(...needles) { for (var row of this) row.retag("div"); if (needles.length == 0) return; for (var row of this) { var hide = false; var cell = row.get_cell(0); for (var needle of needles) hide = hide || (cell.inner().innerHTML.indexOf(needle) < 0); if (hide) row.retag("hidden"); } } } //////////////////////////////////////////////////////////////////////////////// export class Toolbar extends Widget { static Side = class extends Widget { add(name, tag="div") { return this.tag(tag).text(name); } sep() { return this.tag().text("|").classify("zen_toolbar_sep"); } } constructor(parent, inline=false) { var root = parent.tag().classify("zen_toolbar"); super(root); if (inline) root.classify("zen_toolbar_inline"); this._left = new Toolbar.Side(root.tag()); this._right = new Toolbar.Side(root.tag()); } left() { return this._left; } right() { return this._right; } } //////////////////////////////////////////////////////////////////////////////// export class ProgressBar extends Widget { constructor(parent) { const root = parent.tag().classify("zen_progressbar"); super(root); this._label = root.tag(); root.tag(); // bg this._bar = root.tag(); } set_progress(what, count=0, end=1) { const percent = (((count * 100) / end) | 0).toString() + "%"; this._bar.style("width", percent); this._label.text(`${what}... ${count}/${end} (${percent})`); } } //////////////////////////////////////////////////////////////////////////////// export class Pager { constructor(section, page_size, on_change, search_fn) { this._page = 0; this._page_size = page_size; this._total = 0; this._on_change = on_change; this._search_fn = search_fn || null; this._search_input = null; const pager = section.tag().classify("module-pager").inner(); this._btn_prev = document.createElement("button"); this._btn_prev.className = "module-pager-btn"; this._btn_prev.textContent = "\u2190 Prev"; this._btn_prev.addEventListener("click", () => this._go_page(this._page - 1)); this._label = document.createElement("span"); this._label.className = "module-pager-label"; this._btn_next = document.createElement("button"); this._btn_next.className = "module-pager-btn"; this._btn_next.textContent = "Next \u2192"; this._btn_next.addEventListener("click", () => this._go_page(this._page + 1)); if (this._search_fn) { this._search_input = document.createElement("input"); this._search_input.type = "text"; this._search_input.className = "module-pager-search"; this._search_input.placeholder = "Search\u2026"; this._search_input.addEventListener("keydown", (e) => { if (e.key === "Enter") { this._do_search(this._search_input.value.trim()); } }); pager.appendChild(this._search_input); } pager.appendChild(this._btn_prev); pager.appendChild(this._label); pager.appendChild(this._btn_next); this._pager = pager; this._update_ui(); } prepend(element) { const ref = this._search_input || this._btn_prev; this._pager.insertBefore(element, ref); } set_total(n) { this._total = n; const max_page = Math.max(0, Math.ceil(n / this._page_size) - 1); if (this._page > max_page) { this._page = max_page; } this._update_ui(); } page_range() { const start = this._page * this._page_size; const end = Math.min(start + this._page_size, this._total); return { start, end }; } _go_page(n) { const max = Math.max(0, Math.ceil(this._total / this._page_size) - 1); this._page = Math.max(0, Math.min(n, max)); this._update_ui(); this._on_change(); } _do_search(term) { if (!term || !this._search_fn) { return; } const result = this._search_fn(term); if (!result) { this._search_input.style.outline = "2px solid var(--theme_fail)"; setTimeout(() => { this._search_input.style.outline = ""; }, 1000); return; } this._go_page(Math.floor(result.index / this._page_size)); flash_highlight(this._pager.parentNode.querySelector(`[zs_name="${CSS.escape(result.name)}"]`)); } _update_ui() { const total = this._total; const page_count = Math.max(1, Math.ceil(total / this._page_size)); const start = this._page * this._page_size + 1; const end = Math.min(start + this._page_size - 1, total); this._btn_prev.disabled = this._page === 0; this._btn_next.disabled = this._page >= page_count - 1; this._label.textContent = total === 0 ? "No items" : `${start}\u2013${end} of ${total}`; } static make_search_fn(get_data, get_key) { return (term) => { const t = term.toLowerCase(); const data = get_data(); const i = data.findIndex(item => get_key(item).toLowerCase().includes(t)); return i < 0 ? null : { index: i, name: get_key(data[i]) }; }; } static loading(section) { return section.tag().classify("pager-loading").text("Loading\u2026").inner(); } } //////////////////////////////////////////////////////////////////////////////// export class WidgetHost { constructor(parent, depth=1) { this._parent = parent; this._depth = depth; } add_section(name) { var node = this._parent.tag(); if (this._depth == 1) node.classify("zen_sector"); node.tag("h" + this._depth).text(name); return new WidgetHost(node, this._depth + 1); } add_widget(type, ...args) { if (!(type.prototype instanceof Widget)) throw Error("Incorrect widget type"); return new type(this._parent, ...args); } tag(...args) { return this._parent.tag(...args); } }