diff --git a/app/views/analyse/replay.scala b/app/views/analyse/replay.scala index bd555564a4648..5fc753da3efad 100644 --- a/app/views/analyse/replay.scala +++ b/app/views/analyse/replay.scala @@ -79,6 +79,7 @@ def replay( .css(ctx.blind.option("round.nvui")) .css(ctx.pref.hasKeyboardMove.option("keyboardMove")) .i18n(_.puzzle, _.study) + .i18nOpt(ctx.blind, _.keyboardMove) .js(analyseNvuiTag) .js( bits.analyseModule( diff --git a/app/views/study.scala b/app/views/study.scala index ec7b11aaee197..3dd856b1784f8 100644 --- a/app/views/study.scala +++ b/app/views/study.scala @@ -51,6 +51,7 @@ def show( .css("analyse.study") .css(ctx.pref.hasKeyboardMove.option("keyboardMove")) .i18n(_.puzzle, _.study) + .i18nOpt(ctx.blind, _.keyboardMove) .js(analyseNvuiTag) .js( PageModule( diff --git a/modules/analyse/src/main/ui/AnalyseUi.scala b/modules/analyse/src/main/ui/AnalyseUi.scala index e44e01c05e50e..e9f548c473024 100644 --- a/modules/analyse/src/main/ui/AnalyseUi.scala +++ b/modules/analyse/src/main/ui/AnalyseUi.scala @@ -24,6 +24,7 @@ final class AnalyseUi(helpers: Helpers)(externalEngineEndpoint: String): .css(ctx.pref.hasKeyboardMove.option("keyboardMove")) .csp(csp.compose(_.withExternalAnalysisApis)) .i18n(_.puzzle, _.study) + .i18nOpt(ctx.blind, _.keyboardMove) .graph( title = "Chess analysis board", url = s"$netBaseUrl${routes.UserAnalysis.index.url}", diff --git a/modules/game/src/main/Game.scala b/modules/game/src/main/Game.scala index 8223f0bd1fc70..36f946d5639bc 100644 --- a/modules/game/src/main/Game.scala +++ b/modules/game/src/main/Game.scala @@ -237,18 +237,6 @@ object Game: val unanalysableVariants: Set[Variant] = Variant.list.all.toSet -- analysableVariants - val blindModeVariants: Set[Variant] = Set( - chess.variant.Standard, - chess.variant.Chess960, - chess.variant.KingOfTheHill, - chess.variant.ThreeCheck, - chess.variant.FromPosition, - chess.variant.Antichess, - chess.variant.Atomic, - chess.variant.RacingKings, - chess.variant.Horde - ) - val hordeWhitePawnsSince = instantOf(2015, 4, 11, 10, 0) def isOldHorde(game: Game) = diff --git a/modules/practice/src/main/PracticeUi.scala b/modules/practice/src/main/PracticeUi.scala index 9e11fc3ff00fa..ca5ae3629da6e 100644 --- a/modules/practice/src/main/PracticeUi.scala +++ b/modules/practice/src/main/PracticeUi.scala @@ -16,10 +16,11 @@ final class PracticeUi(helpers: Helpers)( ): import helpers.{ *, given } - def show(us: UserStudy, data: JsonView.JsData)(using Context) = + def show(us: UserStudy, data: JsonView.JsData)(using ctx: Context) = Page(us.practiceStudy.name.value) .css("analyse.practice") .i18n(_.puzzle, _.study) + .i18nOpt(ctx.blind, _.keyboardMove) .js(analyseNvuiTag) .js( PageModule( diff --git a/modules/puzzle/src/main/ui/PuzzleUi.scala b/modules/puzzle/src/main/ui/PuzzleUi.scala index 532ba5e0cc899..78fb6d5c4003b 100644 --- a/modules/puzzle/src/main/ui/PuzzleUi.scala +++ b/modules/puzzle/src/main/ui/PuzzleUi.scala @@ -31,6 +31,7 @@ final class PuzzleUi(helpers: Helpers, val bits: PuzzleBits)( .css(ctx.pref.hasVoice.option("voice")) .css(ctx.blind.option("round.nvui")) .i18n(_.puzzle, _.puzzleTheme, _.storm) + .i18nOpt(ctx.blind, _.keyboardMove) .js(ctx.blind.option(Esm("puzzle.nvui"))) .js( PageModule( diff --git a/modules/round/src/main/ui/RoundUi.scala b/modules/round/src/main/ui/RoundUi.scala index 8608b4b103379..cebb644f24171 100644 --- a/modules/round/src/main/ui/RoundUi.scala +++ b/modules/round/src/main/ui/RoundUi.scala @@ -16,6 +16,7 @@ final class RoundUi(helpers: Helpers, gameUi: lila.game.ui.GameUi): .css(ctx.pref.hasKeyboardMove.option("keyboardMove")) .css(ctx.pref.hasVoice.option("voice")) .css(ctx.blind.option("round.nvui")) + .i18nOpt(ctx.blind, _.keyboardMove) .zoom .csp(_.withPeer.withWebAssembly) diff --git a/ui/analyse/src/ctrl.ts b/ui/analyse/src/ctrl.ts index 5fcf46c2549b5..6c79e9af7997d 100644 --- a/ui/analyse/src/ctrl.ts +++ b/ui/analyse/src/ctrl.ts @@ -140,7 +140,10 @@ export default class AnalyseCtrl { if (this.data.forecast) this.forecast = new ForecastCtrl(this.data.forecast, this.data, redraw); if (this.opts.wiki) this.wiki = wikiTheory(); if (site.blindMode) - site.asset.loadEsm('analyse.nvui', { init: this }).then(nvui => (this.nvui = nvui)); + site.asset.loadEsm('analyse.nvui', { init: this }).then(nvui => { + this.nvui = nvui; + this.redraw(); + }); this.instanciateEvalCache(); diff --git a/ui/analyse/src/ground.ts b/ui/analyse/src/ground.ts index 83271f82a5c56..1830080e899bc 100644 --- a/ui/analyse/src/ground.ts +++ b/ui/analyse/src/ground.ts @@ -10,7 +10,6 @@ export const render = (ctrl: AnalyseCtrl): VNode => h('div.cg-wrap.cgv' + ctrl.cgVersion.js, { hook: { insert: vnode => ctrl.setChessground(makeChessground(vnode.elm as HTMLElement, makeConfig(ctrl))), - destroy: _ => ctrl.chessground.destroy(), }, }); diff --git a/ui/analyse/src/plugins/analyse.nvui.ts b/ui/analyse/src/plugins/analyse.nvui.ts index 6268c89c1603b..c63cd67efb4a5 100644 --- a/ui/analyse/src/plugins/analyse.nvui.ts +++ b/ui/analyse/src/plugins/analyse.nvui.ts @@ -1,13 +1,12 @@ -import { h, type VNode } from 'snabbdom'; +import { h, type VNode, type VNodeChildren } from 'snabbdom'; import { defined, prop, type Prop } from 'common'; import { text as xhrText } from 'common/xhr'; import type AnalyseController from '../ctrl'; import { makeConfig as makeCgConfig } from '../ground'; -import type { AnalyseData } from '../interfaces'; +import type { AnalyseData, NvuiPlugin } from '../interfaces'; import type { Player } from 'game'; -import viewStatus from 'game/view/status'; import { - type Style, + type MoveStyle, renderSan, renderPieces, renderBoard, @@ -29,12 +28,12 @@ import { } from 'nvui/chess'; import { renderSetting } from 'nvui/setting'; import { Notify } from 'nvui/notify'; -import { commands } from 'nvui/command'; -import { bind, type MaybeVNodes } from 'common/snabbdom'; +import { commands, boardCommands } from 'nvui/command'; +import { bind, onInsert, type MaybeVNode, type MaybeVNodes } from 'common/snabbdom'; import { throttle } from 'common/timing'; import explorerView from '../explorer/explorerView'; import { ops, path as treePath } from 'tree'; -import { view as cevalView, renderEval } from 'ceval'; +import { view as cevalView, renderEval, type CevalCtrl } from 'ceval'; import { next, prev } from '../control'; import { lichessRules } from 'chessops/compat'; import { makeSan } from 'chessops/san'; @@ -44,13 +43,16 @@ import { setupPosition } from 'chessops/variant'; import { plyToTurn } from 'chess'; import { Chessground as makeChessground } from 'chessground'; import { pubsub } from 'common/pubsub'; +import { renderResult } from '../view/components'; +import { view as chapterNewFormView } from '../study/chapterNewForm'; +import { view as chapterEditFormView } from '../study/chapterEditForm'; const throttled = (sound: string) => throttle(100, () => site.sound.play(sound)); const selectSound = throttled('select'); const borderSound = throttled('outOfBound'); const errorSound = throttled('error'); -export function initModule(ctrl: AnalyseController) { +export function initModule(ctrl: AnalyseController): NvuiPlugin { const notify = new Notify(), moveStyle = styleSetting(), pieceStyle = pieceSetting(), @@ -70,21 +72,21 @@ export function initModule(ctrl: AnalyseController) { notify.redraw = ctrl.redraw; const d = ctrl.data, style = moveStyle.get(); - if (!ctrl.chessground) - ctrl.chessground = makeChessground(document.createElement('div'), { - ...makeCgConfig(ctrl), - animation: { enabled: false }, - drawable: { enabled: false }, - coordinates: false, - }); + ctrl.chessground = makeChessground(document.createElement('div'), { + ...makeCgConfig(ctrl), + animation: { enabled: false }, + drawable: { enabled: false }, + coordinates: false, + }); return h('main.analyse', [ h('div.nvui', [ + studyDetails(ctrl), h('h1', 'Textual representation'), h('h2', 'Game info'), ...['white', 'black'].map((color: Color) => h('p', [color + ' player: ', renderPlayer(ctrl, playerByColor(d, color))]), ), - h('p', `${d.game.rated ? 'Rated' : 'Casual'} ${d.game.perf}`), + h('p', `${d.game.rated ? 'Rated' : 'Casual'} ${d.game.perf || d.game.variant.name}`), d.clock ? h('p', `Clock: ${d.clock.initial / 60} + ${d.clock.increment}`) : null, h('h2', 'Moves'), h('p.moves', { attrs: { role: 'log', 'aria-live': 'off' } }, renderCurrentLine(ctrl, style)), @@ -103,7 +105,7 @@ export function initModule(ctrl: AnalyseController) { : []), h('h2', 'Pieces'), h('div.pieces', renderPieces(ctrl.chessground.state.pieces, style)), - ...renderResult(ctrl), + ...renderAriaResult(ctrl), h('h2', 'Current position'), h( 'p.position.lastMove', @@ -119,7 +121,7 @@ export function initModule(ctrl: AnalyseController) { insert(vnode) { const $form = $(vnode.elm as HTMLFormElement), $input = $form.find('.move').val(''); - $input[0]!.focus(); + $input[0]?.focus(); $form.on('submit', onSubmit(ctrl, notify.set, moveStyle.get, $input)); }, }, @@ -134,8 +136,6 @@ export function initModule(ctrl: AnalyseController) { ], ), notify.render(), - // h('h2', 'Actions'), - // h('div.actions', tableInner(ctrl)), h('h2', 'Computer analysis'), ...cevalView.renderCeval(ctrl), cevalView.renderPvs(ctrl), @@ -165,7 +165,7 @@ export function initModule(ctrl: AnalyseController) { }, renderBoard( ctrl.chessground.state.pieces, - ctrl.data.player.color, + ctrl.data.game.variant.key === 'racingKings' ? 'white' : ctrl.data.player.color, pieceStyle.get(), prefixStyle.get(), positionStyle.get(), @@ -203,40 +203,18 @@ export function initModule(ctrl: AnalyseController) { h('p', [ 'Use arrow keys to navigate in the game.', h('br'), - 'l: toggle local computer analysis', + `l: ${i18n.site.toggleLocalAnalysis}`, h('br'), - 'z: toggle all computer analysis', + `z: ${i18n.site.toggleAllAnalysis}`, h('br'), - 'space: play best computer move', + `space: ${i18n.site.playComputerMove}`, h('br'), 'c: announce computer evaluation', h('br'), - 'x: show threat', - h('br'), - ]), - h('h2', 'Board mode commands'), - h('p', [ - 'Use these commands when focused on the board itself.', - h('br'), - 'o: announce current position.', - h('br'), - "c: announce last move's captured piece.", - h('br'), - 'l: display last move.', - h('br'), - 't: display clocks.', - h('br'), - 'arrow keys: move left, right, up or down.', - h('br'), - 'kqrbnp/KQRBNP: move forward/backward to a piece.', - h('br'), - '1-8: move to rank 1-8.', - h('br'), - 'Shift+1-8: move to file a-h.', - h('br'), - '', + `x: ${i18n.site.showThreat}`, h('br'), ]), + ...boardCommands(), h('h2', 'Commands'), h('p', [ 'Type these commands in the command input.', @@ -263,51 +241,36 @@ export function initModule(ctrl: AnalyseController) { }; } -const NOT_ALLOWED = 'local evaluation not allowed'; -const NOT_POSSIBLE = 'local evaluation not possible'; -const NOT_ENABLED = 'local evaluation not enabled'; - function renderEvalAndDepth(ctrl: AnalyseController): string { - let evalStr: string, depthStr: string; - if (ctrl.threatMode()) { - evalStr = evalInfo(ctrl.node.threat); - depthStr = depthInfo(ctrl.node.threat, false); - return `${evalInfo(ctrl.node.threat)} ${depthInfo(ctrl.node.threat, false)}`; - } else { - const evs = ctrl.currentEvals(), - bestEv = cevalView.getBestEval(evs); - evalStr = evalInfo(bestEv); - depthStr = depthInfo(evs.client, !!evs.client?.cloud); - } - if (!evalStr) { - if (!ctrl.ceval.allowed()) return NOT_ALLOWED; - else if (!ctrl.ceval.possible) return NOT_POSSIBLE; - else return NOT_ENABLED; - } else { - return evalStr + ' ' + depthStr; - } + if (ctrl.threatMode()) return `${evalInfo(ctrl.node.threat)} ${depthInfo(ctrl.node.threat, false)}`; + const evs = ctrl.currentEvals(), + bestEv = cevalView.getBestEval(evs); + const evalStr = evalInfo(bestEv); + return !evalStr ? noEvalStr(ctrl.ceval) : `${evalStr} ${depthInfo(evs.client, !!evs.client?.cloud)}`; } -function evalInfo(bestEv: EvalScore | undefined): string { - if (bestEv) { - if (defined(bestEv.cp)) return renderEval(bestEv.cp).replace('-', '−'); - else if (defined(bestEv.mate)) - return `mate in ${Math.abs(bestEv.mate)} for ${bestEv.mate > 0 ? 'white' : 'black'}`; - } - return ''; -} +const evalInfo = (bestEv: EvalScore | undefined): string => + defined(bestEv?.cp) + ? renderEval(bestEv.cp).replace('-', '−') + : defined(bestEv?.mate) + ? `mate in ${Math.abs(bestEv.mate)} for ${bestEv.mate > 0 ? 'white' : 'black'}` + : ''; -function depthInfo(clientEv: Tree.ClientEval | undefined, isCloud: boolean): string { - if (!clientEv) return ''; - const depth = clientEv.depth || 0; - return i18n.site.depthX(depth) + isCloud ? ' Cloud' : ''; -} +const depthInfo = (clientEv: Tree.ClientEval | undefined, isCloud: boolean): string => + clientEv ? `${i18n.site.depthX(clientEv.depth || 0)} ${isCloud ? 'Cloud' : ''}` : ''; + +const noEvalStr = (ctrl: CevalCtrl) => + !ctrl.allowed() + ? 'local evaluation not allowed' + : !ctrl.possible + ? 'local evaluation not possible' + : !ctrl.enabled() + ? 'local evaluation not enabled' + : ''; -function renderBestMove(ctrl: AnalyseController, style: Style): string { - const instance = ctrl.getCeval(); - if (!instance.allowed()) return NOT_ALLOWED; - if (!instance.possible) return NOT_POSSIBLE; - if (!instance.enabled()) return NOT_ENABLED; +function renderBestMove(ctrl: AnalyseController, style: MoveStyle): string { + const noEvalMsg = noEvalStr(ctrl.ceval); + if (noEvalMsg) return noEvalMsg; const node = ctrl.node, setup = parseFen(node.fen).unwrap(); let pvs: Tree.PvData[] = []; @@ -315,44 +278,26 @@ function renderBestMove(ctrl: AnalyseController, style: Style): string { pvs = node.threat.pvs; setup.turn = opposite(setup.turn); if (setup.turn === 'white') setup.fullmoves += 1; - } else if (node.ceval) { - pvs = node.ceval.pvs; - } - const pos = setupPosition(lichessRules(instance.opts.variant.key), setup); + } else if (node.ceval) pvs = node.ceval.pvs; + const pos = setupPosition(lichessRules(ctrl.ceval.opts.variant.key), setup); if (pos.isOk && pvs.length > 0 && pvs[0].moves.length > 0) { const uci = pvs[0].moves[0]; const san = makeSan(pos.unwrap(), parseUci(uci)!); return renderSan(san, uci, style); - } else { - return ''; } + return ''; } -function renderResult(ctrl: AnalyseController): VNode[] { - if (ctrl.data.game.status.id >= 30) { - let result; - switch (ctrl.data.game.winner) { - case 'white': - result = '1-0'; - break; - case 'black': - result = '0-1'; - break; - default: - result = '½-½'; - } - return [ - h('h2', 'Game status'), - h('div.status', { attrs: { role: 'status', 'aria-live': 'assertive', 'aria-atomic': 'true' } }, [ - h('div.result', result), - h('div.status', viewStatus(ctrl)), - ]), - ]; - } - return []; +function renderAriaResult(ctrl: AnalyseController): VNode[] { + const result = renderResult(ctrl); + const res = result.length ? result : 'No result'; + return [ + h('h2', 'Game status'), + h('div.status', { attrs: { role: 'status', 'aria-live': 'assertive', 'aria-atomic': 'true' } }, res), + ]; } -function renderCurrentLine(ctrl: AnalyseController, style: Style): (string | VNode)[] { +function renderCurrentLine(ctrl: AnalyseController, style: MoveStyle): VNodeChildren { if (ctrl.path.length === 0) { return renderMainline(ctrl.mainline, ctrl.path, style); } else { @@ -361,8 +306,14 @@ function renderCurrentLine(ctrl: AnalyseController, style: Style): (string | VNo } } -function onSubmit(ctrl: AnalyseController, notify: (txt: string) => void, style: () => Style, $input: Cash) { - return function () { +function onSubmit( + ctrl: AnalyseController, + notify: (txt: string) => void, + style: () => MoveStyle, + $input: Cash, +) { + return (e: SubmitEvent) => { + e.preventDefault(); let input = castlingFlavours(($input.val() as string).trim()); if (isShortCommand(input)) input = '/' + input; if (input[0] === '/') onCommand(ctrl, notify, input.slice(1), style()); @@ -373,31 +324,23 @@ function onSubmit(ctrl: AnalyseController, notify: (txt: string) => void, style: else notify('Invalid command'); } $input.val(''); - return false; }; } -const shortCommands = ['p', 's', 'next', 'prev', 'eval', 'best']; - -function isShortCommand(input: string): boolean { - return shortCommands.includes(input.split(' ')[0].toLowerCase()); -} +const isShortCommand = (input: string) => + ['p', 's', 'next', 'prev', 'eval', 'best'].includes(input.split(' ')[0].toLowerCase()); -function onCommand(ctrl: AnalyseController, notify: (txt: string) => void, c: string, style: Style) { +function onCommand(ctrl: AnalyseController, notify: (txt: string) => void, c: string, style: MoveStyle) { const lowered = c.toLowerCase(); - if (lowered === 'next') { - next(ctrl); - ctrl.redraw(); - } else if (lowered === 'prev') { - prev(ctrl); + const doAndRedraw = (fn: (ctrl: AnalyseController) => void): void => { + fn(ctrl); ctrl.redraw(); - } else if (lowered === 'next line') { - jumpNextLine(ctrl); - ctrl.redraw(); - } else if (lowered === 'prev line') { - jumpPrevLine(ctrl); - ctrl.redraw(); - } else if (lowered === 'eval') notify(renderEvalAndDepth(ctrl)); + }; + if (lowered === 'next') doAndRedraw(next); + else if (lowered === 'prev') doAndRedraw(prev); + else if (lowered === 'next line') doAndRedraw(jumpNextLine); + else if (lowered === 'prev line') doAndRedraw(jumpPrevLine); + else if (lowered === 'eval') notify(renderEvalAndDepth(ctrl)); else if (lowered === 'best') notify(renderBestMove(ctrl, style)); else { const pieces = ctrl.chessground.state.pieces; @@ -409,18 +352,14 @@ function onCommand(ctrl: AnalyseController, notify: (txt: string) => void, c: st } } -const analysisGlyphs = ['?!', '?', '??']; - -function renderAcpl(ctrl: AnalyseController, style: Style): MaybeVNodes | undefined { - const anal = ctrl.data.analysis; +function renderAcpl(ctrl: AnalyseController, style: MoveStyle): MaybeVNodes | undefined { + const anal = ctrl.data.analysis; // heh if (!anal) return undefined; - const analysisNodes = ctrl.mainline.filter(n => - (n.glyphs || []).find(g => analysisGlyphs.includes(g.symbol)), - ); + const analysisGlyphs = ['?!', '?', '??']; + const analysisNodes = ctrl.mainline.filter(n => n.glyphs?.find(g => analysisGlyphs.includes(g.symbol))); const res: Array = []; ['white', 'black'].forEach((color: Color) => { - const acpl = anal[color].acpl; - res.push(h('h3', `${color} player: ${acpl} ACPL`)); + res.push(h('h3', `${color} player: ${anal[color].acpl} ACPL`)); res.push( h( 'select', @@ -448,29 +387,30 @@ function renderAcpl(ctrl: AnalyseController, style: Style): MaybeVNodes | undefi return res; } -function requestAnalysisButton( +const requestAnalysisButton = ( ctrl: AnalyseController, inProgress: Prop, notify: (msg: string) => void, -) { - if (inProgress()) return h('p', 'Server-side analysis in progress'); - if (ctrl.ongoing || ctrl.synthetic) return undefined; - return h( - 'button', - { - hook: bind('click', _ => - xhrText(`/${ctrl.data.game.id}/request-analysis`, { method: 'post' }).then( - () => { - inProgress(true); - notify('Server-side analysis in progress'); +): MaybeVNode => + ctrl.ongoing || ctrl.synthetic + ? undefined + : inProgress() + ? h('p', 'Server-side analysis in progress') + : h( + 'button', + { + hook: bind('click', _ => + xhrText(`/${ctrl.data.game.id}/request-analysis`, { method: 'post' }).then( + () => { + inProgress(true); + notify('Server-side analysis in progress'); + }, + () => notify('Cannot run server-side analysis'), + ), + ), }, - () => notify('Cannot run server-side analysis'), - ), - ), - }, - 'Request a computer analysis', - ); -} + i18n.site.requestAComputerAnalysis, + ); function currentLineIndex(ctrl: AnalyseController): { i: number; of: number } { if (ctrl.path === treePath.root) return { i: 1, of: 1 }; @@ -486,7 +426,7 @@ function renderLineIndex(ctrl: AnalyseController): string { return of > 1 ? `, line ${i + 1} of ${of} ,` : ''; } -function renderCurrentNode(ctrl: AnalyseController, style: Style): string { +function renderCurrentNode(ctrl: AnalyseController, style: MoveStyle): string { const node = ctrl.node; if (!node.san || !node.uci) return 'Initial position'; return [ @@ -499,9 +439,8 @@ function renderCurrentNode(ctrl: AnalyseController, style: Style): string { .trim(); } -function renderPlayer(ctrl: AnalyseController, player: Player) { - return player.ai ? i18n.site.aiNameLevelAiLevel('Stockfish', player.ai) : userHtml(ctrl, player); -} +const renderPlayer = (ctrl: AnalyseController, player: Player): VNodeChildren => + player.ai ? i18n.site.aiNameLevelAiLevel('Stockfish', player.ai) : userHtml(ctrl, player); function userHtml(ctrl: AnalyseController, player: Player) { const d = ctrl.data, @@ -523,9 +462,8 @@ function userHtml(ctrl: AnalyseController, player: Player) { : 'Anonymous'; } -function playerByColor(d: AnalyseData, color: Color) { - return color === d.player.color ? d.player : d.opponent; -} +const playerByColor = (d: AnalyseData, color: Color): Player => + color === d.player.color ? d.player : d.opponent; const jumpNextLine = (ctrl: AnalyseController) => jumpLine(ctrl, 1); const jumpPrevLine = (ctrl: AnalyseController) => jumpLine(ctrl, -1); @@ -539,3 +477,82 @@ function jumpLine(ctrl: AnalyseController, delta: number) { const newPath = prevPath + prevNode.children[newI].id; ctrl.userJumpIfCan(newPath); } + +function studyDetails(ctrl: AnalyseController): MaybeVNode { + const study = ctrl.study; + const onInsertHandler = (callback: () => void, el: HTMLElement) => { + el.addEventListener('click', callback); + el.addEventListener('keydown', ev => ev.key === 'Enter' && callback()); + }; + + return ( + study && + h('div.study-details', [ + h('h2', 'Study details'), + h('span', `Title: ${study.data.name}. By: ${study.data.ownerId}`), + h('br'), + h('label.chapters', [ + h('h2', 'Current chapter:'), + h( + 'select', + { + hook: bind('change', (e: InputEvent) => { + const target = e.target as HTMLSelectElement; + const selectedOption = target.options[target.selectedIndex]; + const chapterId = selectedOption.getAttribute('chapterId'); + study.setChapter(chapterId!); + }), + }, + study.chapters.list.all().map((ch, i) => + h( + 'option', + { + attrs: { + selected: ch.id === study.currentChapter().id, + chapterId: ch.id, + }, + }, + `${i + 1}. ${ch.name}`, + ), + ), + ), + study.members.canContribute() + ? h('div.buttons', [ + h( + 'button', + { + hook: onInsert((el: HTMLButtonElement) => { + const toggle = () => { + study.chapters.editForm.toggle(study.currentChapter()); + ctrl.redraw(); + }; + onInsertHandler(toggle, el); + }), + }, + [ + 'Edit current chapter', + study.chapters.editForm.current() && chapterEditFormView(study.chapters.editForm), + ], + ), + h( + 'button', + { + hook: onInsert((el: HTMLButtonElement) => { + const toggle = () => { + study.chapters.newForm.toggle(); + ctrl.redraw(); + }; + onInsertHandler(toggle, el); + }), + }, + [ + 'Add new chapter', + study.chapters.newForm.isOpen() ? chapterNewFormView(study.chapters.newForm) : undefined, + ], + ), + ]) + : undefined, + ]), + ]) + ); +} diff --git a/ui/analyse/src/view/components.ts b/ui/analyse/src/view/components.ts index 88b7f03fe34dc..f0aa4055b3521 100644 --- a/ui/analyse/src/view/components.ts +++ b/ui/analyse/src/view/components.ts @@ -349,36 +349,27 @@ export function renderControls(ctrl: AnalyseCtrl) { ); } -function renderMoveList(ctrl: AnalyseCtrl, deps?: typeof studyDeps, concealOf?: ConcealOf) { - function renderResult(ctrl: AnalyseCtrl, deps?: typeof studyDeps): VNode[] { - const render = (result: string, status: VNodeKids) => [h('div.result', result), h('div.status', status)]; - if (ctrl.data.game.status.id >= 30) { - let result; - switch (ctrl.data.game.winner) { - case 'white': - result = '1-0'; - break; - case 'black': - result = '0-1'; - break; - default: - result = '½-½'; - } - return render(result, statusView(ctrl)); - } else if (ctrl.study) { - const result = deps?.findTag(ctrl.study.data.chapter.tags, 'result'); - if (!result || result === '*') return []; - if (result === '1-0') return render(result, [i18n.site.whiteIsVictorious]); - if (result === '0-1') return render(result, [i18n.site.blackIsVictorious]); - return render('½-½', [i18n.site.draw]); - } - return []; +export function renderResult(ctrl: AnalyseCtrl, deps?: typeof studyDeps): VNode[] { + const render = (result: string, status: VNodeKids) => [h('div.result', result), h('div.status', status)]; + if (ctrl.data.game.status.id >= 30) { + const winner = ctrl.data.game.winner; + const result = winner === 'white' ? '1-0' : winner === 'black' ? '0-1' : '½-½'; + return render(result, statusView(ctrl)); + } else if (ctrl.study) { + const result = deps?.findTag(ctrl.study.data.chapter.tags, 'result'); + if (!result || result === '*') return []; + if (result === '1-0') return render(result, [i18n.site.whiteIsVictorious]); + if (result === '0-1') return render(result, [i18n.site.blackIsVictorious]); + return render('½-½', [i18n.site.draw]); } - return h('div.analyse__moves.areplay', [ + return []; +} + +const renderMoveList = (ctrl: AnalyseCtrl, deps?: typeof studyDeps, concealOf?: ConcealOf): VNode => + h('div.analyse__moves.areplay', [ h(`div.areplay__v${ctrl.treeVersion}`, [renderTreeView(ctrl, concealOf), ...renderResult(ctrl)]), !ctrl.practice && !deps?.gbEdit.running(ctrl) && renderNextChapter(ctrl), ]); -} export const renderMaterialDiffs = (ctrl: AnalyseCtrl): [VNode, VNode] => materialView.renderMaterialDiffs( diff --git a/ui/chess/src/sanWriter.ts b/ui/chess/src/sanWriter.ts index 542db6f0a4380..10b5185617ccc 100644 --- a/ui/chess/src/sanWriter.ts +++ b/ui/chess/src/sanWriter.ts @@ -1,7 +1,7 @@ import { charToRole, type Square } from 'chessops'; export type Board = { pieces: { [key: number]: string }; turn: boolean }; -export type SanToUci = { [key: string]: Uci }; +export type SanToUci = { [key: AlmostSan]: Uci }; function fixCrazySan(san: string) { return san[0] === 'P' ? san.slice(1) : san; @@ -151,39 +151,40 @@ export function sanWriter(fen: string, ucis: string[]): SanToUci { return sans; } +export function sanToUci(san: string, legalSans: SanToUci): Uci | undefined { + if (san in legalSans) return legalSans[san]; + const lowered = san.toLowerCase(); + for (const i in legalSans) if (i.toLowerCase() === lowered) return legalSans[i]; + return; +} + +export const sanToWords = (san: string): string => + san + .split('') + .map(c => { + if (c === 'x') return 'takes'; + if (c === '+') return 'check'; + if (c === '#') return 'checkmate'; + if (c === '=') return 'promotes to'; + if (c === '@') return 'at'; + const code = c.charCodeAt(0); + if (code > 48 && code < 58) return c; // 1-8 + if (code > 96 && code < 105) return c.toUpperCase(); // a-h + return charToRole(c) ?? c; + }) + .join(' ') + .replace('O - O - O', 'long castle') + .replace('O - O', 'short castle'); + export function speakable(san?: San): string { const text = !san ? 'Game start' - : san.includes('O-O-O#') - ? 'long castle checkmate' - : san.includes('O-O-O+') - ? 'long castle check' - : san.includes('O-O-O') - ? 'long castle' - : san.includes('O-O#') - ? 'short castle checkmate' - : san.includes('O-O+') - ? 'short castle check' - : san.includes('O-O') - ? 'short castle' - : san - .split('') - .map(c => { - if (c === 'x') return 'takes'; - if (c === '+') return 'check'; - if (c === '#') return 'checkmate'; - if (c === '=') return 'promotes to'; - if (c === '@') return 'at'; - const code = c.charCodeAt(0); - if (code > 48 && code < 58) return c; // 1-8 - if (code > 96 && code < 105) return c.toUpperCase(); - return charToRole(c) ?? c; - }) - .join(' ') - .replace(/^A /, '"A"') // "A takes" & "A 3" are mispronounced - .replace(/(\d) E (\d)/, '$1,E $2') // Strings such as 1E5 are treated as scientific notation - .replace(/C /, 'c ') // Capital C is pronounced as "degrees celsius" when it comes after a number (e.g. R8c3) - .replace(/F /, 'f ') // Capital F is pronounced as "degrees fahrenheit" when it comes after a number (e.g. R8f3) - .replace(/(\d) H (\d)/, '$1H$2'); // "H" is pronounced as "hour" when it comes after a number with a space (e.g. Rook 5 H 3) + : sanToWords(san) + .replace(/^A /, '"A"') // "A takes" & "A 3" are mispronounced + .replace(/(\d) E (\d)/, '$1,E $2') // Strings such as 1E5 are treated as scientific notation + .replace(/C /, 'c ') // Capital C is pronounced as "degrees celsius" when it comes after a number (e.g. R8c3) + .replace(/F /, 'f ') // Capital F is pronounced as "degrees fahrenheit" when it comes after a number (e.g. R8f3) + .replace(/(\d) H (\d)/, '$1H$2') // "H" is pronounced as "hour" when it comes after a number with a space (e.g. Rook 5 H 3) + .replace(/(\d) H (\d)/, '$1H$2'); return text; } diff --git a/ui/keyboardMove/src/keyboardSubmit.ts b/ui/keyboardMove/src/keyboardSubmit.ts index 14d4c138ee9cc..4833ca0d3fd04 100644 --- a/ui/keyboardMove/src/keyboardSubmit.ts +++ b/ui/keyboardMove/src/keyboardSubmit.ts @@ -1,5 +1,5 @@ import { files } from 'chessground/types'; -import type { SanToUci } from 'chess'; +import { sanToUci, type SanToUci } from 'chess'; import type { Opts } from './exports'; const keyRegex = /^[a-h][1-8]$/; @@ -138,13 +138,6 @@ function iccfToUci(v: string) { return chars.join(''); } -function sanToUci(san: string, legalSans: SanToUci): Uci | undefined { - if (san in legalSans) return legalSans[san]; - const lowered = san.toLowerCase(); - for (const i in legalSans) if (i.toLowerCase() === lowered) return legalSans[i]; - return; -} - function sanCandidates(san: string, legalSans: SanToUci): San[] { // replace '=' in promotion moves (#7326) const lowered = san.replace('=', '').toLowerCase(); diff --git a/ui/nvui/src/chess.ts b/ui/nvui/src/chess.ts index 575def341abb5..ecb51c278ceb5 100644 --- a/ui/nvui/src/chess.ts +++ b/ui/nvui/src/chess.ts @@ -1,26 +1,28 @@ import { h, type VNode, type VNodeChildren } from 'snabbdom'; import { type Pieces, files } from 'chessground/types'; -import { invRanks, allKeys } from 'chessground/util'; import { type Setting, makeSetting } from './setting'; import { parseFen } from 'chessops/fen'; -import { chessgroundDests } from 'chessops/compat'; -import { type SquareName, RULES, type Rules } from 'chessops/types'; +import { chessgroundDests, lichessRules } from 'chessops/compat'; +import { COLORS, RANK_NAMES, ROLES, type FileName } from 'chessops/types'; import { setupPosition } from 'chessops/variant'; -import { charToRole, parseUci, roleToChar } from 'chessops/util'; -import { plyToTurn, type SanToUci, sanWriter } from 'chess'; +import { charToRole, opposite, parseUci, roleToChar } from 'chessops/util'; +import { destsToUcis, plyToTurn, sanToUci, sanToWords, sanWriter } from 'chess'; import { storage } from 'common/storage'; -export type Style = 'uci' | 'san' | 'literate' | 'nato' | 'anna'; -export type PieceStyle = 'letter' | 'white uppercase letter' | 'name' | 'white uppercase name'; -export type PrefixStyle = 'letter' | 'name' | 'none'; +const moveStyles = ['uci', 'san', 'literate', 'nato', 'anna'] as const; +export type MoveStyle = (typeof moveStyles)[number]; +const pieceStyles = ['letter', 'white uppercase letter', 'name', 'white uppercase name'] as const; +export type PieceStyle = (typeof pieceStyles)[number]; +const prefixStyles = ['letter', 'name', 'none'] as const; +export type PrefixStyle = (typeof prefixStyles)[number]; export type PositionStyle = 'before' | 'after' | 'none'; export type BoardStyle = 'plain' | 'table'; interface RoundStep { - uci: Uci; + uci?: Uci; } -const nato: { [letter: string]: string } = { +const nato: { [file in Files]: string } = { a: 'alpha', b: 'bravo', c: 'charlie', @@ -30,7 +32,7 @@ const nato: { [letter: string]: string } = { g: 'golf', h: 'hotel', }; -const anna: { [letter: string]: string } = { +const anna: { [file in Files]: string } = { a: 'anna', b: 'bella', c: 'cesar', @@ -40,7 +42,7 @@ const anna: { [letter: string]: string } = { g: 'gustav', h: 'hector', }; -const skipToFile: { [letter: string]: string } = { +const skipToFile: { [letter: string]: Files } = { '!': 'a', '@': 'b', '#': 'c', @@ -51,15 +53,9 @@ const skipToFile: { [letter: string]: string } = { '*': 'h', }; -export function symbolToFile(char: string): string { - return skipToFile[char] ?? ''; -} +const symbolToFile = (char: string): Files => skipToFile[char] ?? ''; -export function supportedVariant(key: string): boolean { - return ['standard', 'chess960', 'kingOfTheHill', 'threeCheck', 'fromPosition', 'atomic', 'horde'].includes( - key, - ); -} +export const supportedVariant = (key: VariantKey): boolean => key !== 'crazyhouse'; export function boardSetting(): Setting { return makeSetting({ @@ -72,15 +68,9 @@ export function boardSetting(): Setting { }); } -export function styleSetting(): Setting