diff --git a/README.md b/README.md index ba1a7d5..7e3b3e5 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ game name, tier, confidence, OS list, and user score. | `-c` / `--concurrency` | Limit concurrency for the search. | | `--disable_cache` | Bypass the local cache. | | `--clear_cache` | Wipe the local cache. | +| `--old-view` | Use the v1.15.0 blessed-based full-screen TUI instead of the default inline picker. | ```sh protondb-cli "Half-Life" --detail diff --git a/lib/presenter/legacy/blessed.d.ts b/lib/presenter/legacy/blessed.d.ts new file mode 100644 index 0000000..574d611 --- /dev/null +++ b/lib/presenter/legacy/blessed.d.ts @@ -0,0 +1,46 @@ +declare module 'blessed' { + export interface Screen { + append(child: Element): void + render(): void + key(keys: string[], listener: (ch?: unknown, key?: unknown) => void): void + children: Element[] + focused: Element | null + } + + export interface Element { + focus(): void + show(): void + hide(): void + detach(): void + width?: number | string + setData(data: string[][]): void + key(keys: string[], listener: (ch?: unknown, key?: unknown) => void): void + up(offset?: number): void + down(offset?: number): void + selected: number + select(index: number): void + } + + export interface ListTable extends Element { + on(event: string, listener: (...args: unknown[]) => void): void + } + + export interface List extends Element { + on(event: string, listener: (...args: unknown[]) => void): void + items: string[] + ritems: string[] + } + + export interface Box extends Element { + setContent(content: string): void + } + + const blessed: { + screen(options?: Record): Screen + listtable(options?: Record): ListTable + list(options?: Record): List + box(options?: Record): Box + } + + export default blessed +} diff --git a/lib/presenter/legacy/index.ts b/lib/presenter/legacy/index.ts new file mode 100644 index 0000000..f6250cb --- /dev/null +++ b/lib/presenter/legacy/index.ts @@ -0,0 +1,296 @@ +import blessed from 'blessed' +import type { + ListTable as BlessedListTable, + Screen as BlessedScreen, + Element as BlessedElement +} from 'blessed' +import type { GameData } from '../formatter.js' +import { + formatGameName, + wrapCollection, + formatRequirements, + generateRequirementsEntries +} from '../formatter.js' + +const TAG_TIERS: Record = { + platinum: '{#E5E4E2-fg}platinum{/}', + gold: '{yellow-fg}gold{/}', + silver: '{#C0C0C0-fg}silver{/}', + bronze: '{#cd7f32-fg}bronze{/}', + pending: '{magenta-fg}pending{/}', + borked: '{red-fg}borked{/}' +} + +const TAG_CONFIDENCE: Record = { + strong: '{#AAFF00-fg}strong{/}', + good: '{green-fg}good{/}', + moderate: '{yellow-fg}moderate{/}', + low: '{red-fg}low{/}', + inadequate: '{#8B0000-fg}inadequate{/}' +} + +const GAME_NA = '{gray-fg}N/A{/gray-fg}' +const DEFAULT_PADDING_DISPLAY = 15 + +function getTierValue(tier: string | undefined): number { + switch (tier) { + case 'platinum': return 6 + case 'gold': return 5 + case 'silver': return 4 + case 'bronze': return 3 + case 'borked': return 2 + case 'pending': return 1 + default: return 0 + } +} + +function sortGames(games: GameData[]): GameData[] { + return [...games].sort((a, b) => getTierValue(b.tier) - getTierValue(a.tier)) +} + +function formatTier(tier: string | undefined): string { + if (tier && TAG_TIERS[tier]) return TAG_TIERS[tier] + return tier ?? 'N/A' +} + +function formatConfidence(confidence: string | undefined): string { + if (confidence && TAG_CONFIDENCE[confidence]) return TAG_CONFIDENCE[confidence] + return confidence ?? 'N/A' +} + +function formatRow(game: GameData): string[] { + const name = formatGameName(game.name) + const tier = game.protondbNotFound ? GAME_NA : formatTier(game.tier) + const conf = game.protondbNotFound ? GAME_NA : formatConfidence(game.confidence) + return [name, tier, conf] +} + +function generateWrappedEntries( + title: string, + data: string[] | undefined, + _width: number | string | undefined, + gameName: string +): string[][] { + if (data && Array.isArray(data) && data.length > 0) { + const wrappedData = wrapCollection( + data, + 80 - gameName.length - DEFAULT_PADDING_DISPLAY + ) + return wrappedData.map((line) => [title, line]) + } + return [] +} + +function populateDetailTable( + name: string, + tier: string, + confidence: string, + gameData: GameData, + table: BlessedListTable +): void { + let data: string[][] = [ + [`{bold}{red-fg}${name}{/red-fg}{/bold}`, '-'], + ['{bold}Steam ObjectId{/bold}', `${gameData.objectID}`], + [ + '{bold}Steam URL{/bold}', + `https://steamdb.info/app/${gameData.objectID}` + ], + [ + '{bold}Protondb URL{/bold}', + `https://www.protondb.com/app/${gameData.objectID}` + ], + ['{bold}Tier{/bold}', tier], + ['{bold}Confidence{/bold}', confidence], + ['{bold}OS List{/bold}', `${gameData.oslist?.join(',') ?? 'N/A'}`], + [ + '{bold}SteamDB Rating{/bold}', + `${gameData.userScore != null ? `${gameData.userScore}%` : 'N/A'}` + ], + ['{bold}Release Date{/bold}', `${gameData?.release_date?.date ?? '-'}`], + ['{bold}Developers{/bold}', `${gameData?.developers ?? '-'}`], + ['{bold}Publishers{/bold}', `${gameData?.publishers ?? '-'}`] + ] + .concat( + generateWrappedEntries( + '{bold}Tags{/bold}', + gameData.tags, + undefined, + name + ) + ) + .concat( + generateWrappedEntries( + '{bold}Technologies{/bold}', + gameData.technologies, + undefined, + name + ) + ) + + if (gameData.genres && Array.isArray(gameData.genres)) { + const genres = gameData.genres.map((g) => g.description).join(',') + data.push(['{bold}Genres{/bold}', genres]) + } + + if (gameData.recommendations) { + data.push([ + '{bold}Recommendations{/bold}', + `${gameData?.recommendations?.total ?? '-'}` + ]) + } + + const reqs = gameData.requirements + if (reqs) { + if (reqs.minimum && Object.keys(reqs.minimum).length > 0) { + data.push([ + '{bold}{yellow-fg}Minimum Requirements{/yellow-fg}{/bold}', + '-' + ]) + data = generateRequirementsEntries(reqs.minimum, data) + } + if (reqs.recommended && Object.keys(reqs.recommended).length > 0) { + data.push([ + '{bold}{green-fg}Recommended Requirements{/green-fg}{/bold}', + '-' + ]) + data = generateRequirementsEntries(reqs.recommended, data) + } + } + + table.setData(data) +} + +function createDetailView( + gameData: GameData, + table: BlessedListTable, + screen: BlessedScreen +): BlessedElement { + const [name, tier, confidence] = [ + formatGameName(gameData.name), + gameData.protondbNotFound ? GAME_NA : formatTier(gameData.tier), + gameData.protondbNotFound ? GAME_NA : formatConfidence(gameData.confidence) + ] + gameData.requirements = formatRequirements(gameData) + + const detailTable = blessed.listtable({ + tags: true, + mouse: false, + keys: false, + interactive: false, + data: [[]], + border: 'line', + width: '100%', + height: '100%', + scrollable: true, + noCellBorders: false, + style: { + fg: 'white', + bg: 'default', + border: { + fg: 'green', + bg: 'default' + }, + header: { + fg: 'white', + bg: 'default', + bold: true + }, + cell: { + selected: { + fg: 'white', + bg: 'blue' + } + } + } + }) + + table.hide() + screen.append(detailTable) + populateDetailTable(name, tier, confidence, gameData, detailTable) + screen.render() + return detailTable +} + +export function presentLegacyData(data: GameData[]): void { + const games = sortGames(data) + const headers = ['name', 'tier', 'confidence'] + const rows = games.map(formatRow) + const tableData = [headers, ...rows] + + const screen = blessed.screen() + + const table = blessed.listtable({ + tags: true, + keys: false, + mouse: false, + interactive: true, + data: tableData, + border: 'line', + style: { + fg: 'white', + bg: 'default', + border: { + fg: 'green', + bg: 'default' + }, + header: { + fg: 'white', + bg: 'default', + bold: true + }, + cell: { + selected: { + fg: 'white', + bg: 'blue' + } + } + } + }) + + screen.append(table) + + let displayGameView: BlessedElement | null = null + + let lastKeyTime = 0 + function handleNav(direction: number): void { + const now = Date.now() + if (now - lastKeyTime < 100) return + lastKeyTime = now + const idx = table.selected as number + const next = idx + direction + if (next >= 1 && next < rows.length + 1) { + table.select(next) + screen.render() + } + } + + screen.key(['down'], () => handleNav(1)) + screen.key(['j'], () => handleNav(1)) + screen.key(['up'], () => handleNav(-1)) + screen.key(['k'], () => handleNav(-1)) + + screen.key(['enter'], () => { + const idxVal = table.selected + const gameIndex = idxVal - 1 + const game = games[gameIndex] + if (!game) return + displayGameView = createDetailView(game, table, screen) + }) + + table.focus() + + screen.key(['escape', 'q', 'C-c'], () => { + if (displayGameView) { + displayGameView.detach() + table.focus() + table.show() + screen.render() + displayGameView = null + } else { + process.exit(0) + } + }) + + table.select(1) + screen.render() +} diff --git a/lib/process/index.ts b/lib/process/index.ts index 19b337c..d98e647 100644 --- a/lib/process/index.ts +++ b/lib/process/index.ts @@ -16,6 +16,7 @@ export interface ProtondbCLIOptions { clear_cache: boolean detail?: boolean json?: boolean + 'old-view'?: boolean } const config = getConfig() @@ -93,5 +94,11 @@ export default async function start( await cache.write() } + if (protondbCLI['old-view'] && isTty && mode !== 'json') { + const { presentLegacyData } = await import('../presenter/legacy/index.js') + presentLegacyData(result) + return + } + await render(result, { mode, isTty }) } diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 0bc570d..ad03907 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -10,6 +10,7 @@ "license": "GPLv3", "dependencies": { "@clack/prompts": "1.4.0", + "blessed": "0.1.81", "chalk": "5.6.2", "lowdb": "7.0.1", "node-html-parser": "7.1.0", @@ -1317,6 +1318,18 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/blessed": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz", + "integrity": "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==", + "license": "MIT", + "bin": { + "blessed": "bin/tput.js" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -4885,6 +4898,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "blessed": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz", + "integrity": "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==" + }, "boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", diff --git a/package.json b/package.json index 74f5479..2e74340 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "license": "GPLv3", "dependencies": { "@clack/prompts": "1.4.0", + "blessed": "0.1.81", "chalk": "5.6.2", "lowdb": "7.0.1", "node-html-parser": "7.1.0", diff --git a/protondb-cli.ts b/protondb-cli.ts index 06a3bc7..7c8c4b6 100644 --- a/protondb-cli.ts +++ b/protondb-cli.ts @@ -78,6 +78,11 @@ const protondbCLI = yargs(argv) description: 'Emit JSON output (no color, no picker)', default: false }) + .option('old-view', { + type: 'boolean', + description: 'Use the legacy blessed-based full-screen TUI (v1.15.0 style)', + default: false + }) .example([ [ '$0 gta --concurrency 5 --hits 15',