Skip to content

Commit

Permalink
feat: Added emoji autocompletes
Browse files Browse the repository at this point in the history
  • Loading branch information
adam-coster committed Oct 27, 2023
1 parent 6faccba commit 181d69b
Show file tree
Hide file tree
Showing 9 changed files with 259 additions and 123 deletions.
3 changes: 2 additions & 1 deletion packages/cl2-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@
"resolveProvider": "true",
"triggerCharacters": [
"\t",
"@"
"@",
"("
]
},
"hoverProvider": "true"
Expand Down
1 change: 1 addition & 0 deletions packages/cl2-editor/src/quests.autocompletes.mts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class QuestCompletionProvider implements vscode.CompletionItemProvider {
provider,
'\t',
'@',
'(',
),
];
}
Expand Down
40 changes: 24 additions & 16 deletions packages/cl2-editor/src/quests.doc.mts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,22 @@ export class QuestDocument {
const name = this.packed.getMoteName(o);
const item = new vscode.CompletionItem(name);
item.detail = this.packed.getSchema(o.schema_id)?.title;
item.insertText = `${name}@${o.id}`;
item.kind = vscode.CompletionItemKind.Class;
item.insertText =
o.schema_id === 'cl2_emoji' ? name : `${name}@${o.id}`;
item.kind =
o.schema_id === 'cl2_emoji'
? vscode.CompletionItemKind.User
: vscode.CompletionItemKind.Class;
// If this was an emoji autocomplete, we'll want to +1 the cursor
// position to the right so that the cursor ends up after the ')'
// character.
if (o.schema_id === 'cl2_emoji') {
item.command = {
title: 'Move the cursor',
command: 'cursorMove',
arguments: [{ to: 'right' }],
};
}
return item;
});
} else if (c.type === 'labels') {
Expand Down Expand Up @@ -126,33 +140,27 @@ export class QuestDocument {

// Get the line at this position
const line = this.document!.lineAt(cursor.line);
if (line.text.match(/^.(#\w+)?\s*$/)) {
if (line.text.match(/^\/\//)) {
// Then default to adding another comment line
newEdit.insert(this.uri, cursor, '\n// ');
} else if (line.text.match(/^.(#\w+)?\s*$/)) {
// Then we want to replace that line with a blank one
newEdit.delete(this.uri, line.range);
} else if (line.text.match(/^\t(?<name>.*?)\s*(?<moteId>@[a-z_0-9]+)/)) {
// Then we're at the end of a dialog speaker line,
// and probably want to add some dialog
newEdit.insert(this.uri, cursor, '\n>');
newEdit.insert(this.uri, cursor, '\n> ');
} else if (line.text.match(/^(Start|End) Moments:/)) {
// Then we probably want to start a dialog
newEdit.insert(this.uri, cursor, '\n\t');
} else if (line.text.match(/^(Start|End) Requirements:/)) {
// Then we probably want to add a requirement
newEdit.insert(this.uri, cursor, '\n?');
} else if (line.text.match(/^Objectives:/)) {
// Then we probably want to add an objective
newEdit.insert(this.uri, cursor, '\n-');
} else if (line.text.match(/^Clue/)) {
// Then we are probably adding dialog
newEdit.insert(this.uri, cursor, '\n>');
} else if (line.text.match(/^-/)) {
// Then we probably want to add another objective
newEdit.insert(this.uri, cursor, '\n-');
} else if (line.text.match(/^(>|!|\+)/)) {
newEdit.insert(this.uri, cursor, '\n> ');
} else if (line.text.match(/^(>|!|\?)/)) {
// If shifted, we want to add another dialog line
// Otherwise we want to create a new dialog speaker
if (shifted) {
newEdit.insert(this.uri, cursor, `\n${line.text[0]}`);
newEdit.insert(this.uri, cursor, `\n${line.text[0]} `);
} else {
newEdit.insert(this.uri, cursor, '\n\n\t');
}
Expand Down
220 changes: 141 additions & 79 deletions packages/gcdata/src/cl2.quest.parse.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
import { Packed } from './Packed.js';
import { assert } from './assert.js';
import {
ParsedClue,
ParsedDialog,
ParsedEmojiGroup,
ParsedLine,
QuestUpdateResult,
Section,
arrayTagPattern,
getPointerForLabel,
lineIsArrayItem,
linePatterns,
parseIfMatch,
sections,
} from './cl2.quest.types.js';
import { getMomentStyleSchema, getMoteLists } from './cl2.quest.utils.js';
import { createBsArrayKey } from './helpers.js';
import { getMoteLists } from './cl2.quest.utils.js';
import { changedPosition, createBsArrayKey } from './helpers.js';
import { Crashlands2 } from './types.cl2.js';
import { Position, Range } from './types.editor.js';
import { Bschema, Mote } from './types.js';
import { resolvePointerInSchema } from './util.js';
import { Position } from './types.editor.js';
import { Mote } from './types.js';

export function parseStringifiedMote(
text: string,
Expand Down Expand Up @@ -47,30 +45,36 @@ export function parseStringifiedMote(
hovers: [],
edits: [],
completions: [],
parsed: {
clues: [],
quest_end_moments: [],
quest_start_moments: [],
comments: [],
},
};

const lines = text.split(/(\r?\n)/g);

let index = 0;
let lineNumber = 0;

const addHover = (range: Range, subschema: Bschema | undefined) => {
if (subschema?.title || subschema?.description) {
result.hovers.push({
...range,
title: subschema.title,
description: subschema.description,
});
const emojiIdFromName = (name: string | undefined): string | undefined => {
if (!name) {
return undefined;
}
const emoji = motes.emojis.find(
(e) =>
packed.getMoteName(e).toLowerCase() === name?.trim().toLowerCase() ||
e.id === name?.trim(),
);
return emoji?.id;
};

/**
* The lowercased name of the last section we transitioned to.
* (A "section" is created by certain top-level labels, like "Start Moments",
* that can contain multiple entries)
*/
const sectionLabels = new Set(sections);
let section: Section | undefined;
/** The MoteId for the last speaker we saw. Used to figure out who to assign stuff to */
let lastSpeaker: undefined | string;
let lastClue: undefined | ParsedClue;
let lastMomentGroup: 'quest_start_moments' | 'quest_end_moments' | undefined;
let lastEmojiGroup: undefined | ParsedEmojiGroup;

for (const line of lines) {
const trace: any[] = [];
Expand Down Expand Up @@ -109,8 +113,7 @@ export function parseStringifiedMote(
continue;
}

// Find the first matching pattern and pull the values
// from it.
// Find the first matching pattern and pull the values from it.
let parsedLine: null | ParsedLine = null;
for (const pattern of linePatterns) {
parsedLine = parseIfMatch(pattern, line, lineRange.start);
Expand Down Expand Up @@ -170,70 +173,129 @@ export function parseStringifiedMote(
// Track common problems so that we don't need to repeat logic
/** The character where a mote should exist. */
let requiresMote: undefined | { at: Position; options: Mote[] };
let requiresEmoji: undefined | { at: Position; options: string[] };
let requiresEmoji: undefined | { at: Position; options: Mote[] };

// Figure out what data/subschema is represented by this line
// Work through each line type to add diagnostics and completions
const labelLower = parsedLine.label?.value?.toLowerCase();
if (parsedLine.indicator?.value === '\t') {
const indicator = parsedLine.indicator?.value;

// Resets
if (indicator !== '>') {
// Then we need to reset the speaker
lastSpeaker = undefined;
lastClue = undefined;
}
if (indicator !== '!') {
lastEmojiGroup = undefined;
}

// Parsing
if (labelLower === 'start moments') {
lastMomentGroup = 'quest_start_moments';
} else if (labelLower === 'end moments') {
lastMomentGroup = 'quest_end_moments';
}
if (indicator === '\t') {
// No data gets stored here, this is just a convenience marker
// to set the speaker for the next set of dialog lines.
requiresMote = {
at: parsedLine.indicator.end,
at: parsedLine.indicator!.end,
options: motes.allowedSpeakers,
};
} else if (labelLower) {
// Are we starting a new section?
if (sectionLabels.has(labelLower as Section)) {
section = labelLower as Section;
}
const pointer = getPointerForLabel(
labelLower,
parsedLine.arrayTag?.value,
section,
);
let subschema: Bschema | undefined;
trace.push({ pointer, parsedLine, section, moteId: mote.id });
if (pointer) {
subschema = resolvePointerInSchema(pointer, mote, packed);
assert(subschema, `No subschema found for pointer ${pointer}`);
addHover(parsedLine.label!, subschema);
} else if (section?.endsWith('moments')) {
// Then this is a moment style that is not yet implemented.
// It'll be in the form Label#arrayTag: Not Editable
subschema = getMomentStyleSchema(parsedLine.label!.value!, packed);
} else {
throw new Error(`No pointer found for label ${labelLower}`);
}
assert(subschema, `No subschema found for pointer ${pointer}`);
addHover(parsedLine.label!, subschema);
if (['giver', 'receiver'].includes(labelLower)) {
requiresMote = {
at: parsedLine.labelGroup!.end,
options: motes.allowedGivers,
};
} else if (labelLower === 'storyline') {
requiresMote = {
at: parsedLine.labelGroup!.end,
options: motes.storylines,
};
} else if (labelLower === 'clue') {
requiresMote = {
at: parsedLine.labelGroup!.end,
options: motes.allowedSpeakers,
lastSpeaker = parsedLine.moteTag?.value;
} else if (labelLower === 'clue') {
requiresMote = {
at: parsedLine.labelGroup!.end,
options: motes.allowedSpeakers,
};
lastClue = {
id: parsedLine.arrayTag?.value?.trim(),
speaker: parsedLine.moteTag?.value?.trim(),
phrases: [],
};
result.parsed.clues ||= [];
result.parsed.clues.push(lastClue);
} else if (indicator === '>') {
// Then this is a dialog line, either within a Clue or a Dialog Moment
const emoji = emojiIdFromName(parsedLine.emojiName?.value);
if (parsedLine.emojiGroup) {
// Emojis are optional. If we see a "group" (parentheses) then
// that changes to a requirement.
requiresEmoji = {
at: changedPosition(parsedLine.emojiGroup.start, { characters: 1 }),
options: motes.emojis,
};
}
} else {
// Then this is an "indicator" line. Indicators are prefixes that identify the kind of thing we're dealing with. Some of them are unambiguous, others require knowing what section we're in.
const indicator = parsedLine.indicator?.value!;
if (indicator === ':)') {
// Then this is a declaration line for an Emote moment
} else if (indicator === '!') {
// Then this is an emote within a Emote moment
} else if (indicator === '>') {
// Then this is a dialog line, either within a Clue or a Dialog Moment
} else if (indicator === '?') {
// Then this is a non-dialog quest moment
const moment: ParsedDialog = {
id: parsedLine.arrayTag?.value?.trim(),
speaker: lastSpeaker,
emoji,
text: parsedLine.text?.value?.trim() || '',
};
if (lastClue) {
lastClue.phrases.push(moment);
} else if (lastMomentGroup) {
result.parsed[lastMomentGroup].push(moment);
} else {
// Then this is an error!
result.diagnostics.push({
message: `Dialog line without a Clue or Moment!`,
...lineRange,
});
}
} else if (labelLower === 'name') {
result.parsed.name = parsedLine.moteName?.value?.trim();
} else if (labelLower === 'draft') {
result.parsed.draft = parsedLine.moteName?.value?.trim() === 'true';
} else if (labelLower === 'log') {
result.parsed.quest_start_log = parsedLine.moteTag?.value?.trim();
} else if (labelLower === 'storyline') {
// TODO: Storyline stuff
requiresMote = {
at: parsedLine.labelGroup!.end,
options: motes.storylines,
};
} else if (labelLower === 'giver') {
// TODO: Giver stuff
requiresMote = {
at: parsedLine.labelGroup!.end,
options: motes.allowedGivers,
};
} else if (labelLower === 'receiver') {
// TODO: Receiver stuff
requiresMote = {
at: parsedLine.labelGroup!.end,
options: motes.allowedGivers,
};
} else if (indicator === ':)') {
// TODO: Then this is a declaration line for an Emote moment
// TODO: If it has a new ID, add it to the mote!
} else if (indicator === '!') {
// TODO: Then this is an emote within a Emote moment
} else if (indicator === '?') {
// TODO: Then this is a non-dialog quest moment
} else if (indicator === '//') {
// TODO: Handle notes
}

if (requiresEmoji) {
const where = {
start: requiresEmoji.at,
end: parsedLine.emojiGroup!.end,
};
if (!parsedLine.emojiName?.value) {
result.completions.push({
type: 'motes',
options: requiresEmoji.options,
...where,
});
} else if (!emojiIdFromName(parsedLine.emojiName?.value)) {
result.diagnostics.push({
message: `Emoji "${parsedLine.emojiName?.value}" not found!`,
...where,
});
}
}
if (requiresMote) {
if (!parsedLine.moteName || !parsedLine.moteTag) {
const where = {
Expand Down
11 changes: 6 additions & 5 deletions packages/gcdata/src/cl2.quest.stringify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function stringifyMote(mote: Mote<Crashlands2.Quest>, packed: Packed) {
const blocks: string[] = [
`Name: ${packed.getMoteName(mote)}`,
`Storyline: ${packed.getMoteName(storyline)}${moteTag(storyline)}`,
`Draft: ${mote.data.wip?.draft || 'false'}\n`,
`Draft: ${mote.data.wip?.draft ? 'true' : 'false'}\n`,
];

// NOTES
Expand Down Expand Up @@ -98,9 +98,10 @@ export function stringifyMote(mote: Mote<Crashlands2.Quest>, packed: Packed) {
if (moment.speech.speaker !== lastSpeaker) {
line += `\t${characterString(moment.speech.speaker)}\n`;
}
line += `>${arrayTag(momentContainer)} ${emojiString(
moment.speech.emotion,
)}${moment.speech.text.text}`;
const emojiStr = emojiString(moment.speech.emotion);
line += `>${arrayTag(momentContainer)} ${
emojiStr ? emojiStr + ' ' : ''
}${moment.speech.text.text}`;
lastSpeaker = moment.speech.speaker;
} else if (moment.style === 'Emote') {
const emojiLines: string[] = [`:)${arrayTag(momentContainer)}`];
Expand Down Expand Up @@ -133,7 +134,7 @@ export function stringifyMote(mote: Mote<Crashlands2.Quest>, packed: Packed) {
if (!emojiId) return '';
const emoji = packed.getMote(emojiId);
const name = packed.getMoteName(emoji) || emoji.id;
return name ? `(${name}) ` : '';
return name ? `(${name})` : '';
}

function characterString(characterId: string) {
Expand Down
Loading

0 comments on commit 181d69b

Please sign in to comment.