diff --git a/.eslintrc.json b/.eslintrc.json index f76e3874..627cc5b5 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -16,5 +16,10 @@ "ecmaVersion": "latest", "sourceType": "module" }, - "rules": {} + "rules": { + "@typescript-eslint/no-unused-vars": [ + "error", + { "argsIgnorePattern": "^_" } + ] + } } diff --git a/jest.config.js b/jest.config.js index b3f4a169..b368773f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,4 +6,5 @@ module.exports = { }, testEnvironment: "jsdom", rootDir: "src", + modulePaths: [""], }; diff --git a/src/characters/action-context.ts b/src/characters/action-context.ts new file mode 100644 index 00000000..25c62730 --- /dev/null +++ b/src/characters/action-context.ts @@ -0,0 +1,96 @@ +import { Move } from "@datasworn/core"; +import { CharacterContext } from "../character-tracker"; +import { movesReader, rollablesReader } from "../characters/lens"; +import { type Datastore } from "../datastore"; +import ForgedPlugin from "../index"; +import { MeterCommon } from "../rules/ruleset"; +import { InfoModal } from "../utils/ui/info"; + +export interface ActionContext { + readonly moves: Move[]; + readonly rollables: { + key: string; + value?: number | undefined; + definition: MeterCommon; + }[]; + readonly momentum?: number; +} + +export class NoCharacterActionConext implements ActionContext { + constructor(public readonly datastore: Datastore) {} + + get moves(): Move[] { + return this.datastore.moves; + } + + get rollables(): { + key: string; + value?: number | undefined; + definition: MeterCommon; + }[] { + return Object.entries(this.datastore.ruleset.stats).map(([key, stat]) => ({ + key, + definition: stat, + })); + } + + get momentum() { + return undefined; + } +} + +export class CharacterActionContext implements ActionContext { + constructor( + public readonly datastore: Datastore, + public readonly characterPath: string, + public readonly characterContext: CharacterContext, + ) {} + + get moves() { + const characterMoves = movesReader( + this.characterContext.lens, + this.datastore.index, + ) + .get(this.characterContext.character) + .expect("unexpected failure finding assets for moves"); + + return this.datastore.moves.concat(characterMoves); + } + + get rollables(): { key: string; value?: number; definition: MeterCommon }[] { + return rollablesReader( + this.characterContext.lens, + this.datastore.index, + ).get(this.characterContext.character); + } + + get momentum() { + return this.characterContext.lens.momentum.get( + this.characterContext.character, + ); + } +} +export async function determineCharacterActionContext( + plugin: ForgedPlugin, +): Promise { + if (plugin.settings.useCharacterSystem) { + try { + const [characterPath, characterContext] = + plugin.characters.activeCharacter(); + return new CharacterActionContext( + plugin.datastore, + characterPath, + characterContext, + ); + } catch (e) { + // TODO: probably want to show character parse errors in full glory + await InfoModal.show( + plugin.app, + `An error occurred while finding your active character.\n\n${e}`, + ); + return undefined; + } + } else { + return new NoCharacterActionConext(plugin.datastore); + } +} diff --git a/src/characters/meter-commands.ts b/src/characters/meter-commands.ts index 41a19325..b318e613 100644 --- a/src/characters/meter-commands.ts +++ b/src/characters/meter-commands.ts @@ -1,7 +1,7 @@ import { updatePreviousMoveOrCreateBlock } from "mechanics/editor"; import { Editor } from "obsidian"; import { ConditionMeterDefinition } from "rules/ruleset"; -import { MoveBlockFormat } from "settings/ui"; +import { MoveBlockFormat } from "settings"; import { node } from "utils/kdl"; import { updating } from "utils/lens"; import { vaultProcess } from "utils/obsidian"; diff --git a/src/index.ts b/src/index.ts index 7af9785c..8dc2fd85 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,27 +1,24 @@ import { addAssetToCharacter } from "characters/commands"; import { generateEntityCommand } from "entity/command"; import { IndexManager } from "indexer/manager"; +import { runMoveCommand } from "moves/action"; import { Plugin, type Editor, type MarkdownFileInfo, type MarkdownView, } from "obsidian"; +import { DEFAULT_SETTINGS, ForgedPluginSettings } from "settings"; import { ProgressContext } from "tracks/context"; import { ForgedAPI } from "./api"; import { CharacterIndexer, CharacterTracker } from "./character-tracker"; import * as meterCommands from "./characters/meter-commands"; import { Datastore } from "./datastore"; import registerMechanicsBlock from "./mechanics/mechanics-blocks"; -import { runMoveCommand } from "./moves/action"; import { registerMoveBlock } from "./moves/block"; import { runOracleCommand } from "./oracles/command"; import { registerOracleBlock } from "./oracles/render"; -import { - DEFAULT_SETTINGS, - ForgedPluginSettings, - ForgedSettingTab, -} from "./settings/ui"; +import { ForgedSettingTab } from "./settings/ui"; import { ClockIndex, ClockIndexer } from "./tracks/clock-file"; import { advanceClock, @@ -95,38 +92,22 @@ export default class ForgedPlugin extends Plugin { id: "make-a-move", name: "Make a Move", icon: "zap", - editorCallback: async ( - editor: Editor, - view: MarkdownView | MarkdownFileInfo, - ) => { - // TODO: what if it is just a fileinfo? - await runMoveCommand( - this.app, - this.datastore, - new ProgressContext(this), - this.characters, - editor, - view as MarkdownView, - this.settings, - ); - }, + editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => + // TODO: what if view is just a fileinfo? + runMoveCommand(this, editor, view as MarkdownView), }); this.addCommand({ id: "ask-the-oracle", name: "Ask the Oracle", icon: "help-circle", - editorCallback: async ( - editor: Editor, - view: MarkdownView | MarkdownFileInfo, - ) => { - await runOracleCommand( + editorCallback: (editor: Editor, view: MarkdownView | MarkdownFileInfo) => + runOracleCommand( this.app, this.datastore, editor, view as MarkdownView, - ); - }, + ), }); this.addCommand({ diff --git a/src/moves/action-modal.ts b/src/moves/action/action-modal.ts similarity index 80% rename from src/moves/action-modal.ts rename to src/moves/action/action-modal.ts index 969c6634..a8bc98dc 100644 --- a/src/moves/action-modal.ts +++ b/src/moves/action/action-modal.ts @@ -1,9 +1,9 @@ import { MoveActionRoll } from "@datasworn/core"; import { App, Modal, Setting } from "obsidian"; -import { CharacterContext } from "../character-tracker"; -import { MomentumTracker, momentumTrackerReader } from "../characters/lens"; -import { ActionMoveDescription } from "./desc"; -import { ActionMoveWrapper, formatRollResult } from "./wrapper"; +import { CharacterContext } from "../../character-tracker"; +import { MomentumTracker, momentumTrackerReader } from "../../characters/lens"; +import { ActionMoveDescription } from "../desc"; +import { ActionMoveWrapper, formatRollResult } from "../wrapper"; export async function checkForMomentumBurn( app: App, @@ -19,8 +19,10 @@ export async function checkForMomentumBurn( new ActionModal(app, move, roll, momentumTracker, resolve, reject).open(); }); if (shouldBurn) { - // TODO: if this is true, maybe I should use momentumTracker.reset or something like that? - // or do the reset and then look at the new values? + // Instead of generating this value here, an alternative would be for this function + // to return its _intent_ to burn momentum. And then it could use the actual + // character lens command to reset it and then record the results. That _should_ + // yield the same result, but would eliminate one possible source of divergence. return Object.assign({}, roll.move, { burn: { orig: momentumTracker.momentum, diff --git a/src/moves/adds-modal.ts b/src/moves/action/adds-modal.ts similarity index 100% rename from src/moves/adds-modal.ts rename to src/moves/action/adds-modal.ts diff --git a/src/moves/action/format.ts b/src/moves/action/format.ts new file mode 100644 index 00000000..181a6057 --- /dev/null +++ b/src/moves/action/format.ts @@ -0,0 +1,105 @@ +import { Document, Node } from "kdljs"; +import { Editor, stringifyYaml } from "obsidian"; +import { MoveBlockFormat } from "settings"; +import { createOrAppendMechanics } from "../../mechanics/editor"; +import { node } from "../../utils/kdl"; +import { MoveDescription, moveIsAction, moveIsProgress } from "../desc"; +import { generateMoveLine } from "../move-line-parser"; + +function generateMechanicsNode(move: MoveDescription): Document { + const children: Node[] = []; + if (moveIsAction(move)) { + const adds = (move.adds ?? []).reduce((acc, { amount }) => acc + amount, 0); + + // Add "add" nodes for each non-zero add + children.push( + ...(move.adds ?? []) + .filter(({ amount }) => amount != 0) + .map(({ amount, desc }) => + node("add", { values: [amount, ...(desc ? [desc] : [])] }), + ), + ); + + // Main roll node + children.push( + node("roll", { + values: [move.stat], + properties: { + action: move.action, + stat: move.statVal, + adds, + vs1: move.challenge1, + vs2: move.challenge2, + }, + }), + ); + + // Momentum burn + if (move.burn) { + children.push( + node("burn", { + properties: { from: move.burn.orig, to: move.burn.reset }, + }), + ); + } + } else if (moveIsProgress(move)) { + children.push( + node("progress-roll", { + properties: { + // TODO: what about progress track id? + // TODO: use a ticks prop instead... or at least use a helper to get this + score: Math.floor(move.progressTicks / 4), + vs1: move.challenge1, + vs2: move.challenge2, + }, + }), + ); + } else { + throw new Error("what kind of move is this?"); + } + + // TODO: move name vs move id + const doc: Document = [ + node("move", { + values: [move.name], + children, + }), + ]; + return doc; +} +function mechanicsMoveRenderer( + editor: Editor, +): (move: MoveDescription) => void { + return (move) => createOrAppendMechanics(editor, generateMechanicsNode(move)); +} + +export function getMoveRenderer( + format: MoveBlockFormat, + editor: Editor, +): (move: MoveDescription) => void { + switch (format) { + case MoveBlockFormat.MoveLine: + return moveLineMoveRenderer(editor); + case MoveBlockFormat.YAML: + return yamlMoveRenderer(editor); + case MoveBlockFormat.Mechanics: + return mechanicsMoveRenderer(editor); + } +} +export function yamlMoveRenderer( + editor: Editor, +): (move: MoveDescription) => void { + return (move) => { + editor.replaceSelection(`\`\`\`move\n${stringifyYaml(move)}\n\`\`\`\n\n`); + }; +} + +export function moveLineMoveRenderer( + editor: Editor, +): (move: MoveDescription) => void { + return (move) => { + editor.replaceSelection( + `\`\`\`move\n${generateMoveLine(move)}\n\`\`\`\n\n`, + ); + }; +} diff --git a/src/moves/action.ts b/src/moves/action/index.ts similarity index 54% rename from src/moves/action.ts rename to src/moves/action/index.ts index be5072c6..b56e84e3 100644 --- a/src/moves/action.ts +++ b/src/moves/action/index.ts @@ -4,39 +4,36 @@ import { MoveProgressRoll, TriggerActionRollCondition, } from "@datasworn/core"; -import { DataIndex } from "datastore/data-index"; -import { Document, Node } from "kdljs"; import { - stringifyYaml, + ActionContext, + CharacterActionContext, + determineCharacterActionContext, +} from "characters/action-context"; +import ForgedPlugin from "index"; +import { type App, type Editor, type FuzzyMatch, type MarkdownView, } from "obsidian"; -import { CharacterContext, type CharacterTracker } from "../character-tracker"; -import { momentumOps, movesReader, rollablesReader } from "../characters/lens"; -import { type Datastore } from "../datastore"; -import { createOrAppendMechanics } from "../mechanics/editor"; -import { ForgedPluginSettings, MoveBlockFormat } from "../settings/ui"; -import { ProgressContext } from "../tracks/context"; -import { selectProgressTrack } from "../tracks/select"; -import { ProgressTrackWriterContext } from "../tracks/writer"; -import { randomInt } from "../utils/dice"; -import { node } from "../utils/kdl"; -import { vaultProcess } from "../utils/obsidian"; -import { CustomSuggestModal } from "../utils/suggest"; -import { checkForMomentumBurn } from "./action-modal"; -import { AddsModal } from "./adds-modal"; +import { MeterCommon } from "rules/ruleset"; +import { momentumOps } from "../../characters/lens"; +import { ProgressContext } from "../../tracks/context"; +import { selectProgressTrack } from "../../tracks/select"; +import { ProgressTrackWriterContext } from "../../tracks/writer"; +import { randomInt } from "../../utils/dice"; +import { vaultProcess } from "../../utils/obsidian"; +import { CustomSuggestModal } from "../../utils/suggest"; import { ActionMoveAdd, - moveIsAction, - moveIsProgress, type ActionMoveDescription, type MoveDescription, type ProgressMoveDescription, -} from "./desc"; -import { generateMoveLine } from "./move-line-parser"; -import { ActionMoveWrapper } from "./wrapper"; +} from "../desc"; +import { ActionMoveWrapper } from "../wrapper"; +import { checkForMomentumBurn } from "./action-modal"; +import { AddsModal } from "./adds-modal"; +import { getMoveRenderer } from "./format"; enum MoveKind { Progress = "Progress", @@ -105,20 +102,6 @@ function processProgressMove( }; } -function yamlMoveRenderer(editor: Editor): (move: MoveDescription) => void { - return (move) => { - editor.replaceSelection(`\`\`\`move\n${stringifyYaml(move)}\n\`\`\`\n\n`); - }; -} - -function moveLineMoveRenderer(editor: Editor): (move: MoveDescription) => void { - return (move) => { - editor.replaceSelection( - `\`\`\`move\n${generateMoveLine(move)}\n\`\`\`\n\n`, - ); - }; -} - export function validAdds(baseStat: number): number[] { const adds = []; for (let add = 0; 1 + baseStat + add <= 10; add++) { @@ -127,141 +110,49 @@ export function validAdds(baseStat: number): number[] { return adds; } -function generateMechanicsNode(move: MoveDescription): Document { - const children: Node[] = []; - if (moveIsAction(move)) { - const adds = (move.adds ?? []).reduce((acc, { amount }) => acc + amount, 0); - - // Add "add" nodes for each non-zero add - children.push( - ...(move.adds ?? []) - .filter(({ amount }) => amount != 0) - .map(({ amount, desc }) => - node("add", { values: [amount, ...(desc ? [desc] : [])] }), - ), - ); - - // Main roll node - children.push( - node("roll", { - values: [move.stat], - properties: { - action: move.action, - stat: move.statVal, - adds, - vs1: move.challenge1, - vs2: move.challenge2, - }, - }), - ); - - // Momentum burn - if (move.burn) { - children.push( - node("burn", { - properties: { from: move.burn.orig, to: move.burn.reset }, - }), - ); - } - } else if (moveIsProgress(move)) { - children.push( - node("progress-roll", { - properties: { - // TODO: what about progress track id? - // TODO: use a ticks prop instead... or at least use a helper to get this - score: Math.floor(move.progressTicks / 4), - vs1: move.challenge1, - vs2: move.challenge2, - }, - }), - ); - } else { - throw new Error("what kind of move is this?"); - } - - // TODO: move name vs move id - const doc: Document = [ - node("move", { - values: [move.name], - children, - }), - ]; - return doc; -} - -function mechanicsMoveRenderer( - editor: Editor, -): (move: MoveDescription) => void { - return (move) => createOrAppendMechanics(editor, generateMechanicsNode(move)); -} - -export function getMoveRenderer( - format: MoveBlockFormat, - editor: Editor, -): (move: MoveDescription) => void { - switch (format) { - case MoveBlockFormat.MoveLine: - return moveLineMoveRenderer(editor); - case MoveBlockFormat.YAML: - return yamlMoveRenderer(editor); - case MoveBlockFormat.Mechanics: - return mechanicsMoveRenderer(editor); - } -} - export async function runMoveCommand( - app: App, - datastore: Datastore, - progressContext: ProgressContext, - characters: CharacterTracker, + plugin: ForgedPlugin, editor: Editor, view: MarkdownView, - settings: ForgedPluginSettings, ): Promise { if (view.file?.path == null) { console.error("No file for view. Why?"); return; } - const [characterPath, context] = characters.activeCharacter(); - - const { character, lens } = context; - - const characterMoves = movesReader(lens, datastore.index) - .get(character) - .expect("unexpected failure finding assets for moves"); - - const allMoves = datastore.moves - .concat(characterMoves) - .filter( - (move) => - move.roll_type == "action_roll" || move.roll_type == "progress_roll", - ); + const context = await determineCharacterActionContext(plugin); + if (!context) { + // No available/selected character + return; + } - const moveRenderer: (move: MoveDescription) => void = getMoveRenderer( - settings.moveBlockFormat, - editor, + const allMoves = context.moves.filter( + (move) => + move.roll_type == "action_roll" || move.roll_type == "progress_roll", ); const move = await promptForMove( - app, + plugin.app, allMoves.sort((a, b) => a.name.localeCompare(b.name)), ); + const moveRenderer: (move: MoveDescription) => void = getMoveRenderer( + plugin.settings.moveBlockFormat, + editor, + ); + let moveDescription: MoveDescription; switch (move.roll_type) { case "action_roll": { - moveDescription = await handleActionRoll( - context, - app, - move, - characterPath, - datastore.index, - ); + moveDescription = await handleActionRoll(context, plugin.app, move); break; } case "progress_roll": { - moveDescription = await handleProgressRoll(app, progressContext, move); + moveDescription = await handleProgressRoll( + plugin.app, + new ProgressContext(plugin), + move, + ); break; } case "no_roll": @@ -307,16 +198,9 @@ const ORDINALS = [ "tenth", ]; -// TODO: refactor this so it returns the description and handle the other parts separately? -async function handleActionRoll( - charContext: CharacterContext, - app: App, +function suggestedRollablesForMove( move: MoveActionRoll, - characterPath: string, - dataIndex: DataIndex, -) { - const { lens, character } = charContext; - +): Record>> { const suggestedRollables: Record< string, Array> @@ -347,11 +231,20 @@ async function handleActionRoll( suggestedRollables[rollableToAdd].push(conditionSpec); } } + return suggestedRollables; +} - const stat = await CustomSuggestModal.select( +async function handleActionRoll( + actionContext: ActionContext, + app: App, + move: MoveActionRoll, +) { + const suggestedRollables = suggestedRollablesForMove(move); + const availableRollables = actionContext.rollables; + + const choice = await CustomSuggestModal.selectWithUserEntry( app, - rollablesReader(lens, dataIndex) - .get(character) + availableRollables .map((meter) => { return { ...meter, condition: suggestedRollables[meter.key] ?? [] }; }) @@ -362,12 +255,15 @@ async function handleActionRoll( return 1; } else { return ( - b.value - a.value || + (b.value ?? 0) - (a.value ?? 0) || a.definition.label.localeCompare(b.definition.label) ); } }), - (m) => `${m.definition.label}: ${m.value ?? "missing (defaults to 0)"}`, + (m) => `${m.definition.label}: ${m.value ?? "unknown"}`, + (input, el) => { + el.setText(`Use custom meter '${input}'`); + }, ({ item }, el) => { if (item.condition.length > 0) { el.createEl("small", { @@ -379,6 +275,33 @@ async function handleActionRoll( move.trigger.text, ); + const stat = + choice.kind == "pick" + ? choice.value + : { + key: choice.custom, + value: undefined, + condition: [], + definition: { + kind: "stat", + label: choice.custom, + min: 0, + max: 10, + rollable: true, + } satisfies MeterCommon, + }; + + // This stat has an unknown value, so we need to prompt the user for a value. + if (!stat.value) { + stat.value = await CustomSuggestModal.select( + app, + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + (n) => n.toString(10), + undefined, + `What is the value of ${stat.definition.label}?`, + ); + } + const adds = []; // TODO: do we need this arbitrary cutoff on adds? just wanted to avoid a kinda infinite loop while (adds.length < 5) { @@ -400,19 +323,23 @@ async function handleActionRoll( let description = processActionMove(move, stat.key, stat.value ?? 0, adds); const wrapper = new ActionMoveWrapper(description); - description = await checkForMomentumBurn( - app, - move as MoveActionRoll, - wrapper, - charContext, - ); - // TODO: maybe this should be pulled up into the other function (even though it only - // applies for action moves. - if (description.burn) { - await charContext.updater( - vaultProcess(app, characterPath), - (character, { lens }) => momentumOps(lens).reset(character), + if (actionContext instanceof CharacterActionContext) { + const { characterContext } = actionContext; + description = await checkForMomentumBurn( + app, + move as MoveActionRoll, + wrapper, + characterContext, ); + // TODO: maybe this should be pulled up into the other function (even though it only + // applies for action moves. + if (description.burn) { + await characterContext.updater( + vaultProcess(app, actionContext.characterPath), + (character, { lens }) => momentumOps(lens).reset(character), + ); + } } + return description; } diff --git a/src/moves/move-line-parser.test.ts b/src/moves/move-line-parser.test.ts index 0b2dd716..56e62ed5 100644 --- a/src/moves/move-line-parser.test.ts +++ b/src/moves/move-line-parser.test.ts @@ -1,4 +1,4 @@ -import { Either, Left, Right } from "../utils/either"; +import { Either, Left, Right } from "utils/either"; import { MoveDescription } from "./desc"; import { generateMoveLine, parseMoveLine } from "./move-line-parser"; diff --git a/src/moves/move-line-parser.ts b/src/moves/move-line-parser.ts index 0f572084..a1703cc5 100644 --- a/src/moves/move-line-parser.ts +++ b/src/moves/move-line-parser.ts @@ -21,7 +21,7 @@ import { whitespace, whole, } from "@nrsk/sigma"; -import { Either, Left, Right } from "../utils/either"; +import { Either, Left, Right } from "utils/either"; import { ActionMoveDescription, BurnDescriptor, diff --git a/src/rules/ruleset.ts b/src/rules/ruleset.ts index 8c0f37ae..da0641d7 100644 --- a/src/rules/ruleset.ts +++ b/src/rules/ruleset.ts @@ -105,7 +105,7 @@ export class Ruleset { ]), ); this.impacts = Object.fromEntries( - Object.entries(rules.impacts).flatMap(([categoryKey, source]) => { + Object.entries(rules.impacts).flatMap(([_categoryKey, source]) => { const category: ImpactCategory = { label: source.label, description: source.description, diff --git a/src/settings/index.ts b/src/settings/index.ts new file mode 100644 index 00000000..1e9c97f2 --- /dev/null +++ b/src/settings/index.ts @@ -0,0 +1,91 @@ +import Handlebars from "handlebars"; +import { ClockFileAdapter } from "tracks/clock-file"; +import { ProgressTrackFileAdapter, ProgressTrackInfo } from "tracks/progress"; + +export enum MoveBlockFormat { + /** Use the original YAML format for generating action blocks. */ + YAML = "yaml", + + /** Use the new one-line move format */ + MoveLine = "move-line", + + /** The newer KDL mechanics format */ + Mechanics = "mechanics", +} + +export interface ForgedPluginSettings + extends Record { + oraclesFolder: string; + momentumResetTemplate: string; + meterAdjTemplate: string; + + /** Which format should the move block be rendered with? */ + moveBlockFormat: MoveBlockFormat; + + /** Use the character system */ + useCharacterSystem: boolean; +} + +export const DEFAULT_SETTINGS: ForgedPluginSettings = { + moveBlockFormat: MoveBlockFormat.Mechanics, + useCharacterSystem: true, + oraclesFolder: "", + momentumResetTemplate: + "> [!mechanics] {{character.name}} burned momentum: {{oldValue}} -> {{newValue}}\n\n", + meterAdjTemplate: + "> [!mechanics] {{character.name}} old {{measure.definition.label}}: {{measure.value}}; new {{measure.definition.label}}: {{newValue}}\n\n", + advanceProgressTemplate: + "> [!progress] [[{{trackPath}}|{{trackInfo.name}}]]: Marked {{steps}} progress ({{trackInfo.track.boxesFilled}} ![[progress-box-4.svg|15]] total)\n> Ticks: {{originalInfo.track.progress}} + {{ticks}} -> {{trackInfo.track.progress}}\n> Milestone: \n\n", + createProgressTemplate: + "> [!progress] New {{trackInfo.trackType}} track: [[{{trackPath}}|{{trackInfo.name}}]]\n> **Difficulty**: {{trackInfo.track.difficulty}}\n> **Additional details**: \n\n", + advanceClockTemplate: + "> [!progress] [[{{clockPath}}|{{clockInfo.name}}]] clock advanced\n>**Progress:** {{clockInfo.clock.progress}} out of {{clockInfo.clock.segments}} segments filled\n> \n> **Cause of Advance:**\n\n", + createClockTemplate: + "> [!progress] [[{{clockPath}}|{{clockInfo.name}}]] clock created\n>**Progress:** {{clockInfo.clock.progress}} out of {{clockInfo.clock.segments}} segments filled\n> \n> **Cause of Advance:**\n\n", +}; + +export type TEMPLATE_TYPES = { + advanceProgressTemplate: AdvanceProgressTemplateParams; + createProgressTemplate: CreateProgressTemplateParams; + advanceClockTemplate: AdvanceClockTemplateParams; + createClockTemplate: CreateClockTemplateParams; +}; + +export type AdvanceProgressTemplateParams = { + trackPath: string; + trackInfo: ProgressTrackInfo; + steps: number; + ticks: number; + originalInfo: ProgressTrackInfo; +}; + +export type CreateProgressTemplateParams = { + trackPath: string; + trackInfo: ProgressTrackFileAdapter; +}; +function compileTemplate( + key: K, +): (settings: ForgedPluginSettings) => (context: TEMPLATE_TYPES[K]) => string { + return (settings) => (context) => + Handlebars.compile(settings[key], { + noEscape: true, + })(context, { allowProtoPropertiesByDefault: true }); +} + +export const advanceProgressTemplate = compileTemplate( + "advanceProgressTemplate", +); +export const createProgressTemplate = compileTemplate("createProgressTemplate"); +export const advanceClockTemplate = compileTemplate("advanceClockTemplate"); +export const createClockTemplate = compileTemplate("createClockTemplate"); + +export type AdvanceClockTemplateParams = { + clockPath: string; + clockInfo: ClockFileAdapter; + ticks: number; +}; + +export type CreateClockTemplateParams = { + clockPath: string; + clockInfo: ClockFileAdapter; +}; diff --git a/src/settings/ui.ts b/src/settings/ui.ts index 4db60578..af8b7cc8 100644 --- a/src/settings/ui.ts +++ b/src/settings/ui.ts @@ -1,8 +1,6 @@ -import Handlebars from "handlebars"; import ForgedPlugin from "index"; import { PluginSettingTab, Setting, type App } from "obsidian"; -import { ClockFileAdapter } from "tracks/clock-file"; -import { ProgressTrackFileAdapter, ProgressTrackInfo } from "tracks/progress"; +import { ForgedPluginSettings } from "settings"; export class ForgedSettingTab extends PluginSettingTab { plugin: ForgedPlugin; @@ -12,8 +10,17 @@ export class ForgedSettingTab extends PluginSettingTab { this.plugin = plugin; } + async updateSetting( + key: K, + value: ForgedPluginSettings[K], + ) { + this.plugin.settings[key] = value; + await this.plugin.saveSettings(); + } + display(): void { const { containerEl } = this; + const { settings } = this.plugin; containerEl.empty(); @@ -23,95 +30,19 @@ export class ForgedSettingTab extends PluginSettingTab { .addText((text) => text .setPlaceholder("Folder name") - .setValue(this.plugin.settings.oraclesFolder) - .onChange(async (value) => { - this.plugin.settings.oraclesFolder = value; - await this.plugin.saveSettings(); - }), + .setValue(settings.oraclesFolder) + .onChange((value) => this.updateSetting("oraclesFolder", value)), ); - } -} - -export enum MoveBlockFormat { - /** Use the original YAML format for generating action blocks. */ - YAML = "yaml", - - /** Use the new one-line move format */ - MoveLine = "move-line", - - /** The newer KDL mechanics format */ - Mechanics = "mechanics", -} - -export interface ForgedPluginSettings - extends Record { - oraclesFolder: string; - momentumResetTemplate: string; - meterAdjTemplate: string; - - /** Which format should the move block be rendered with? */ - moveBlockFormat: MoveBlockFormat; -} -export const DEFAULT_SETTINGS: ForgedPluginSettings = { - moveBlockFormat: MoveBlockFormat.Mechanics, - oraclesFolder: "", - momentumResetTemplate: - "> [!mechanics] {{character.name}} burned momentum: {{oldValue}} -> {{newValue}}\n\n", - meterAdjTemplate: - "> [!mechanics] {{character.name}} old {{measure.definition.label}}: {{measure.value}}; new {{measure.definition.label}}: {{newValue}}\n\n", - advanceProgressTemplate: - "> [!progress] [[{{trackPath}}|{{trackInfo.name}}]]: Marked {{steps}} progress ({{trackInfo.track.boxesFilled}} ![[progress-box-4.svg|15]] total)\n> Ticks: {{originalInfo.track.progress}} + {{ticks}} -> {{trackInfo.track.progress}}\n> Milestone: \n\n", - createProgressTemplate: - "> [!progress] New {{trackInfo.trackType}} track: [[{{trackPath}}|{{trackInfo.name}}]]\n> **Difficulty**: {{trackInfo.track.difficulty}}\n> **Additional details**: \n\n", - advanceClockTemplate: - "> [!progress] [[{{clockPath}}|{{clockInfo.name}}]] clock advanced\n>**Progress:** {{clockInfo.clock.progress}} out of {{clockInfo.clock.segments}} segments filled\n> \n> **Cause of Advance:**\n\n", - createClockTemplate: - "> [!progress] [[{{clockPath}}|{{clockInfo.name}}]] clock created\n>**Progress:** {{clockInfo.clock.progress}} out of {{clockInfo.clock.segments}} segments filled\n> \n> **Cause of Advance:**\n\n", -}; - -export type TEMPLATE_TYPES = { - advanceProgressTemplate: AdvanceProgressTemplateParams; - createProgressTemplate: CreateProgressTemplateParams; - advanceClockTemplate: AdvanceClockTemplateParams; - createClockTemplate: CreateClockTemplateParams; -}; - -export type AdvanceProgressTemplateParams = { - trackPath: string; - trackInfo: ProgressTrackInfo; - steps: number; - ticks: number; - originalInfo: ProgressTrackInfo; -}; -export type CreateProgressTemplateParams = { - trackPath: string; - trackInfo: ProgressTrackFileAdapter; -}; - -function compileTemplate( - key: K, -): (settings: ForgedPluginSettings) => (context: TEMPLATE_TYPES[K]) => string { - return (settings) => (context) => - Handlebars.compile(settings[key], { - noEscape: true, - })(context, { allowProtoPropertiesByDefault: true }); + new Setting(containerEl) + .setName("Use character system") + .setDesc( + "If enabled (default), the plugin will look for an active character when making moves. If disabled, you will be prompted to supply appropriate values when needed.", + ) + .addToggle((toggle) => + toggle + .setValue(settings.useCharacterSystem) + .onChange((value) => this.updateSetting("useCharacterSystem", value)), + ); + } } - -export const advanceProgressTemplate = compileTemplate( - "advanceProgressTemplate", -); -export const createProgressTemplate = compileTemplate("createProgressTemplate"); -export const advanceClockTemplate = compileTemplate("advanceClockTemplate"); -export const createClockTemplate = compileTemplate("createClockTemplate"); - -export type AdvanceClockTemplateParams = { - clockPath: string; - clockInfo: ClockFileAdapter; - ticks: number; -}; - -export type CreateClockTemplateParams = { - clockPath: string; - clockInfo: ClockFileAdapter; -}; diff --git a/src/tracks/commands.ts b/src/tracks/commands.ts index fe97c8e6..31cd5399 100644 --- a/src/tracks/commands.ts +++ b/src/tracks/commands.ts @@ -5,7 +5,7 @@ import { advanceClockTemplate, advanceProgressTemplate, createProgressTemplate, -} from "../settings/ui"; +} from "settings"; import { vaultProcess } from "../utils/obsidian"; import { CustomSuggestModal } from "../utils/suggest"; import { ClockIndex, clockUpdater } from "./clock-file"; diff --git a/src/utils/suggest.ts b/src/utils/suggest.ts index b5b04617..61c235e2 100644 --- a/src/utils/suggest.ts +++ b/src/utils/suggest.ts @@ -27,6 +27,10 @@ export function processMatches( } } +export type UserChoice = + | { kind: "custom"; custom: string } + | { kind: "pick"; value: T }; + export class CustomSuggestModal extends SuggestModal> { private resolved: boolean = false; @@ -66,6 +70,50 @@ export class CustomSuggestModal extends SuggestModal> { }); } + /** Allow user to select from a list or a custom option. */ + static async selectWithUserEntry( + app: App, + items: T[], + getItemText: (item: T) => string, + renderUserEntry: (input: string, el: HTMLElement) => void, + renderExtras?: (match: FuzzyMatch, el: HTMLElement) => void, + placeholder?: string, + ): Promise> { + return await new Promise((resolve, reject) => { + new this>( + app, + items.map((value) => ({ kind: "pick", value })), + (choice) => + choice.kind == "custom" ? choice.custom : getItemText(choice.value), + ({ item, match }, el) => { + if (item.kind == "custom") { + el.createDiv(undefined, (div) => renderUserEntry(item.custom, div)); + } else { + el.createDiv(undefined, (div) => { + processMatches( + getItemText(item.value), + match, + (text) => { + div.appendText(text); + }, + (text) => { + div.createEl("strong", { text }); + }, + ); + }); + if (renderExtras != null) { + renderExtras({ match, item: item.value }, el); + } + } + }, + resolve, + reject, + placeholder, + (custom) => ({ kind: "custom", custom }), + ).open(); + }); + } + static async selectCustom( app: App, items: T[], @@ -97,6 +145,7 @@ export class CustomSuggestModal extends SuggestModal> { protected readonly onSelect: (item: T) => void, protected readonly onCancel: () => void, placeholder?: string, + protected readonly customItem?: (input: string) => T, ) { super(app); if (placeholder) { @@ -108,7 +157,7 @@ export class CustomSuggestModal extends SuggestModal> { query: string, ): Array> | Promise>> { const fuzzyScore = prepareFuzzySearch(query); - const results = this.items.flatMap((item) => { + const results: FuzzyMatch[] = this.items.flatMap((item) => { const match = fuzzyScore(this.getTtemText(item)); return match != null ? [ @@ -119,6 +168,13 @@ export class CustomSuggestModal extends SuggestModal> { ] : []; }); + if (query != "" && this.customItem) { + results.push({ + item: this.customItem(query), + match: { score: Number.NEGATIVE_INFINITY, matches: [] }, + }); + } + console.log(results); sortSearchResults(results); return results; } diff --git a/src/utils/ui/info.ts b/src/utils/ui/info.ts new file mode 100644 index 00000000..168c65d9 --- /dev/null +++ b/src/utils/ui/info.ts @@ -0,0 +1,34 @@ +import { Modal, Setting } from "obsidian"; + +import { type App } from "obsidian"; + +/** Modal to render an informative prompt to the user. */ +export class InfoModal extends Modal { + static async show(app: App, content: string): Promise { + return await new Promise((resolve, _reject) => { + new this(app, content, resolve).open(); + }); + } + + private constructor( + app: App, + public readonly content: string, + public readonly accept: () => void, + ) { + super(app); + this.setContent(content); + new Setting(this.contentEl).addButton((button) => { + button + .setCta() + .setButtonText("Okay") + .onClick(() => { + this.close(); + }); + }); + } + + onClose(): void { + super.onClose(); + this.accept(); + } +} diff --git a/src/utils/ui/prompt.ts b/src/utils/ui/prompt.ts index 56f3c697..e369f18f 100644 --- a/src/utils/ui/prompt.ts +++ b/src/utils/ui/prompt.ts @@ -46,7 +46,7 @@ export class PromptModal extends SuggestModal> { } getSuggestions( - query: string, + _query: string, ): Array> | Promise>> { return []; }