Complete Note Links
Type [[ in the editor to pick another note from a popup and insert a Markdown link to it. GtkSourceView shows the popup and handles the keyboard; the app supplies the suggestions from a TypeScript class that implements a GtkSourceView interface.
Implement a GObject interface
GtkSourceView asks completion providers for suggestions, called proposals. A provider is a GObject that implements the GtkSource.CompletionProvider interface, and each proposal is a GObject that implements GtkSource.CompletionProposal. A new file, src/editor/note-link-provider.ts, defines both:
import * as Gio from "@gtkx/gi/gio";
import * as GObject from "@gtkx/gi/gobject";
import type * as Gtk from "@gtkx/gi/gtk";
import * as GtkSource from "@gtkx/gi/gtksource";
import { registerClass } from "@gtkx/runtime";
const OPEN = "[[";
class NoteLinkProposalBase
extends GObject.Object
implements GtkSource.CompletionProposalImpl
{
name = "";
vfuncGetTypedText(): string {
return this.name;
}
}
const NoteLinkProposal = registerClass(NoteLinkProposalBase, {
typeName: "NotesNoteLinkProposal",
implements: [GtkSource.CompletionProposal],
});
type NoteLinkProposal = InstanceType<typeof NoteLinkProposal>;
const openLinkAt = (
iter: Gtk.TextIter,
): { start: Gtk.TextIter; query: string } | null => {
const start = iter.copy();
start.setLineOffset(0);
const before = start.getText(iter);
const open = before.lastIndexOf(OPEN);
const query = before.slice(open + OPEN.length);
if (open === -1 || query.includes("]")) {
return null;
}
start.forwardChars([...before.slice(0, open)].length);
return { start, query };
};
const escape = (text: string, special: RegExp): string =>
text.replace(special, "\\$&");
const markdownLink = (name: string): string =>
`[${escape(name, /[\\[\]]/g)}](<${escape(`${name}.md`, /[\\<>]/g)}>)`;
const proposals = (
names: readonly string[],
context: GtkSource.CompletionContext,
): NoteLinkProposal[] => {
const [, , end] = context.getBounds();
const query = openLinkAt(end)?.query.toLocaleLowerCase();
if (query === undefined) {
return [];
}
const rank = (name: string): number =>
name.toLocaleLowerCase().startsWith(query) ? 0 : 1;
return names
.filter((name) => name.toLocaleLowerCase().includes(query))
.toSorted((a, b) => rank(a) - rank(b) || a.localeCompare(b))
.map((name) => {
const proposal = new NoteLinkProposal();
proposal.name = name;
return proposal;
});
};
class NoteLinkProviderBase
extends GObject.Object
implements GtkSource.CompletionProviderImpl
{
noteNames: () => readonly string[] = () => [];
vfuncIsTrigger(iter: Gtk.TextIter, ch: string): boolean {
return ch === "[" && openLinkAt(iter)?.query === "";
}
vfuncPopulateAsync(
context: GtkSource.CompletionContext,
cancellable: Gio.Cancellable | null,
callback: Gio.AsyncReadyCallback | null,
): void {
const model = Gio.ListStore.new(NoteLinkProposal);
model.splice(0, 0, proposals(this.noteNames(), context));
const value = new GObject.Value();
value.init(Gio.ListStore);
value.setObject(model);
Gio.Task.new(this, cancellable, callback).returnValue(value);
}
vfuncPopulateFinish(result: Gio.AsyncResult): Gio.ListModel {
const [, model] =
result instanceof Gio.Task
? result.propagateValue()
: [false, null];
if (!(model instanceof Gio.ListStore)) {
throw new Error("Completion task returned no model");
}
return model;
}
vfuncRefilter(
context: GtkSource.CompletionContext,
model: Gio.ListModel,
): void {
if (model instanceof Gio.ListStore) {
model.splice(
0,
model.getNItems(),
proposals(this.noteNames(), context),
);
}
}
vfuncDisplay(
_context: GtkSource.CompletionContext,
proposal: GtkSource.CompletionProposal,
cell: GtkSource.CompletionCell,
): void {
if (!(proposal instanceof NoteLinkProposal)) {
return;
}
switch (cell.getColumn()) {
case GtkSource.CompletionColumn.ICON:
cell.setIconName("text-x-generic-symbolic");
break;
case GtkSource.CompletionColumn.TYPED_TEXT:
cell.setText(proposal.name);
break;
}
}
vfuncActivate(
context: GtkSource.CompletionContext,
proposal: GtkSource.CompletionProposal,
): void {
const buffer = context.getBuffer();
const [, , end] = context.getBounds();
const link = openLinkAt(end);
if (
buffer === null ||
link === null ||
!(proposal instanceof NoteLinkProposal)
) {
return;
}
buffer.beginUserAction();
buffer.delete(link.start, end);
buffer.insert(link.start, markdownLink(proposal.name), -1);
buffer.endUserAction();
}
}
const NoteLinkProvider = registerClass(NoteLinkProviderBase, {
typeName: "NotesNoteLinkProvider",
implements: [GtkSource.CompletionProvider],
});
type NoteLinkProvider = InstanceType<typeof NoteLinkProvider>;
export { NoteLinkProvider };registerClassregisters each class as a native GObject type, with the interface inimplements. Each interface method is avfuncmethod on the class, and the generatedImpltype, such asGtkSource.CompletionProviderImpl, checks its signature. See Implementing an interface.NoteLinkProposalholds one note name in a plain field, andvfuncGetTypedTextreturns it as the proposal's text.NoteLinkProviderreads the names to offer from itsnoteNamesfunction, which the editor replaces in Attach it to the editor.
Offer note names
GtkSourceView calls vfuncIsTrigger when you type a character that can't be part of a word, such as [, and the provider opens the popup when that character is the second [ of [[. openLinkAt finds the last [[ before the cursor on its line and returns where it starts and the query typed after it, or null once a ] closes it.
proposals keeps the names that contain the query, ignoring case, and sorts the names that start with it first, then alphabetically. The Gio.ListStore that holds them is created for the registered NoteLinkProposal type.
GtkSourceView requests proposals asynchronously: it calls vfuncPopulateAsync with a callback, and vfuncPopulateFinish once the callback runs. The names are already in memory, so vfuncPopulateAsync completes a Gio.Task at once. The task's returnValue takes the model in a GObject.Value initialized for Gio.ListStore, as Passing a GValue describes. propagateValue unpacks it typed as unknown, so vfuncPopulateFinish checks that it is a Gio.ListStore before returning it.
As you keep typing, GtkSourceView calls vfuncRefilter with the same model, and the provider replaces its contents with the proposals for the longer query.
vfuncDisplay fills each row of the popup with a document icon and the note's name. vfuncActivate runs when you choose a proposal. It replaces [[ and the query with a Markdown link to the note's file, such as [Recipes](<Recipes.md>):
- The angle brackets allow spaces in the file name, and
escapeadds a backslash before the characters that would end the link text or the file name early. beginUserActionandendUserActiongroup the deletion and the insertion, so a single undo restores what you typed.
Attach it to the editor
NoteScreen passes the names of the other notes to the editor as linkTargets. useMemo builds the list again only when the notes or the open note change. openLinkedNote moves out of the component and calls openNote from Window actions, so the screen no longer needs its navigation prop:
import * as Gtk from "@gtkx/gi/gtk";
import { useTranslation } from "@gtkx/i18n";
import { AdwStatusPage } from "@gtkx/jsx/adw";
import { GtkStack, GtkStackPage } from "@gtkx/jsx/gtk";
import type { SplitViewScreenProps } from "@gtkx/navigation";
import { useSetting } from "@gtkx/react";
import { useMemo } from "react";
import { openNote, type RootParamList } from "../navigation.js";
import { linkedNoteName } from "../notes/files.js";
import { useNotes } from "../notes/notes-provider.js";
import { MarkdownPreview } from "../preview/markdown-preview.js";
import { schema, useAppSettings } from "../settings.js";
import { NoteEditor } from "../editor/note-editor.js";
const openLinkedNote = (uri: string): boolean => {
const name = linkedNoteName(uri);
if (name === null) {
return false;
}
openNote(name);
return true;
};
const NoteScreen = ({ route }: SplitViewScreenProps<RootParamList, "Note">) => {
const { t } = useTranslation();
const { notes, edit } = useNotes();
const [preview] = useSetting(useAppSettings(), schema, "preview");
const { name } = route.params;
const note = notes?.get(name);
const linkTargets = useMemo(
() => [...(notes?.keys() ?? [])].filter((other) => other !== name),
[notes, name],
);
if (note === undefined) {
return (
<AdwStatusPage
iconName="edit-find-symbolic"
title={t("Note Not Found")}
/>
);
}
return (
<GtkStack
visibleChildName={preview ? "preview" : "editor"}
transitionType={Gtk.StackTransitionType.CROSSFADE}
>
<GtkStackPage name="editor">
<NoteEditor
key={name}
text={note.text}
linkTargets={linkTargets}
onChange={(text) => edit(name, text)}
/>
</GtkStackPage>
<GtkStackPage name="preview">
<MarkdownPreview
text={note.body}
tags={note.tags}
onActivateLink={openLinkedNote}
/>
</GtkStackPage>
</GtkStack>
);
};
export { NoteScreen };NoteEditor keeps the GtkSourceView in state through its callback ref, as Objects that JSX creates shows, and passes it to useLinkCompletion:
import * as Adw from "@gtkx/gi/adw";
import * as Gtk from "@gtkx/gi/gtk";
import * as GtkSource from "@gtkx/gi/gtksource";
import { useTranslation } from "@gtkx/i18n";
import { GtkScrolledWindow } from "@gtkx/jsx/gtk";
import { GtkSourceBuffer, GtkSourceView } from "@gtkx/jsx/gtksource";
import { useProperty } from "@gtkx/react";
import { useEffect, useRef, useState } from "react";
import { NoteLinkProvider } from "./note-link-provider.js";
const markdown = GtkSource.LanguageManager.getDefault().getLanguage("markdown");
const useLinkCompletion = (
view: GtkSource.View | null,
linkTargets: readonly string[],
): void => {
const latestTargets = useRef(linkTargets);
useEffect(() => {
latestTargets.current = linkTargets;
}, [linkTargets]);
useEffect(() => {
if (view === null) {
return;
}
const provider = new NoteLinkProvider();
provider.noteNames = () => latestTargets.current;
const completion = view.getCompletion();
completion.selectOnShow = true;
completion.addProvider(provider);
return () => completion.removeProvider(provider);
}, [view]);
};
type NoteEditorProps = {
text: string;
linkTargets: readonly string[];
onChange: (text: string) => void;
};
const NoteEditor = ({ text, linkTargets, onChange }: NoteEditorProps) => {
const { t } = useTranslation();
const isDark = useProperty(Adw.StyleManager.getDefault(), "dark");
const styleScheme = GtkSource.StyleSchemeManager.getDefault().getScheme(
isDark ? "Adwaita-dark" : "Adwaita",
);
const [view, setView] = useState<GtkSource.View | null>(null);
useLinkCompletion(view, linkTargets);
return (
<GtkScrolledWindow vexpand hscrollbarPolicy={Gtk.PolicyType.NEVER}>
<GtkSourceView
ref={setView}
accessibleLabel={t("Note text")}
wrapMode={Gtk.WrapMode.WORD_CHAR}
topMargin={18}
bottomMargin={18}
leftMargin={18}
rightMargin={18}
buffer={
<GtkSourceBuffer
text={text}
language={markdown}
styleScheme={styleScheme}
onChanged={(buffer) => {
onChange(
buffer.getText(
buffer.getStartIter(),
buffer.getEndIter(),
false,
),
);
}}
/>
}
/>
</GtkScrolledWindow>
);
};
export { NoteEditor };- Once the view exists, the second effect adds a provider to the view's completion, and its cleanup removes the provider when the editor unmounts.
- The provider reads the names through
latestTargets, a ref that the first effect keeps current. A new list of notes reaches the provider without replacing it. selectOnShowselects the first proposal when the popup opens, so Enter inserts it.
Run it
Open Book club and type [[ on a new line: a popup lists the other notes, with the first one selected. Type re to narrow it to Recipes and Website relaunch, and press Enter to insert a link to Recipes. Press Ctrl+Z and the link turns back into [[re. Insert it again, switch to the preview and click the link to open Recipes.


src/editor/note-editor.tsx above, after typing [[ in Book club.