---
description: "Implement a GtkSourceView completion provider in TypeScript to suggest links to other notes."
---

# 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`](https://gnome.pages.gitlab.gnome.org/gtksourceview/gtksourceview5/iface.CompletionProvider.html) interface, and each proposal is a GObject that implements [`GtkSource.CompletionProposal`](https://gnome.pages.gitlab.gnome.org/gtksourceview/gtksourceview5/iface.CompletionProposal.html). A new file, `src/editor/note-link-provider.ts`, defines both:

```ts [src/editor/note-link-provider.ts]
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 };
```

- `registerClass` registers each class as a native GObject type, with the interface in `implements`. Each interface method is a `vfunc` method on the class, and the generated `Impl` type, such as `GtkSource.CompletionProviderImpl`, checks its signature. See [Implementing an interface](/v2/guide/subclassing#implementing-an-interface).
- `NoteLinkProposal` holds one note name in a plain field, and `vfuncGetTypedText` returns it as the proposal's text.
- `NoteLinkProvider` reads the names to offer from its `noteNames` function, which the editor replaces in [Attach it to the editor](#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`](https://docs.gtk.org/gio/class.Task.html) at once. The task's `returnValue` takes the model in a `GObject.Value` initialized for `Gio.ListStore`, as [Passing a GValue](/v2/guide/native-values#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 `escape` adds a backslash before the characters that would end the link text or the file name early.
- `beginUserAction` and `endUserAction` group 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](/v2/tutorial/actions-menus-shortcuts#window-actions), so the screen no longer needs its `navigation` prop:

```tsx [src/components/note-screen.tsx]
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](/v2/guide/native-state#objects-that-jsx-creates) shows, and passes it to `useLinkCompletion`:

```tsx [src/editor/note-editor.tsx]
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.
- [`selectOnShow`](https://gnome.pages.gitlab.gnome.org/gtksourceview/gtksourceview5/property.Completion.select-on-show.html) selects 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.

<figure class="result">
    <img class="result-light" src="/figures/tutorial-note-links-light.png" width="720" height="480" loading="lazy" alt="The Notes window editing Book club, with [[ typed on the last line and a completion popup below it listing Meeting notes, Recipes, Trip to Lisbon and Website relaunch, the first one selected." />
    <img class="result-dark" src="/figures/tutorial-note-links-dark.png" width="720" height="480" loading="lazy" alt="The Notes window editing Book club, with [[ typed on the last line and a completion popup below it listing Meeting notes, Recipes, Trip to Lisbon and Website relaunch, the first one selected." />
    <figcaption>Captured from <a href="#attach-it-to-the-editor"><code>src/editor/note-editor.tsx</code></a> above, after typing <code>[[</code> in Book club.</figcaption>
</figure>
