Skip to content

Opening a Task

In Smart Views, Filters, and Search you sliced the store by view, filter, and query. A task on screen is still just a title, with no way to see or set notes, due, createdAt, and completedAt. This page turns the content pane into an editor for one task, writing every field through a single store action.

Opening and closing

Which task is open is view state, so it belongs in the UI slice next to selection. Opening also shows the content pane, because on a collapsed layout the editor is a separate page rather than a neighboring column.

In src/store/ui.ts, add the field and the actions:

ts
 export type UiSlice = {
     selection: Selection;
+    selectedTaskId: string | null;
     collapsed: boolean;
     showContent: boolean;
     filter: Filter;
     searchMode: boolean;
     searchQuery: string;
     select: (selection: Selection) => void;
+    openTask: (id: string) => void;
+    closeTask: () => void;
     setCollapsed: (collapsed: boolean) => void;
ts
 export const createUiSlice: StateCreator<Store, Mutators, [], UiSlice> = (set) => ({
     selection: { kind: "smart", view: "all" },
+    selectedTaskId: null,
     collapsed: false,
ts
     select: (selection) =>
         set((state) => ({
             selection,
+            selectedTaskId: null,
             searchMode: false,
             searchQuery: "",
             showContent: state.collapsed,
         })),
+    openTask: (selectedTaskId) => set({ selectedTaskId, showContent: true }),
+    closeTask: () => set({ selectedTaskId: null }),

select clears selectedTaskId along with the search, so switching lists closes whatever task was open instead of leaving an editor for a task the sidebar no longer points at.

An AdwActionRow responds to clicks only once you mark it activatable, which makes the whole row a target. onActivated then fires when the row is clicked or takes Return from the keyboard.

In src/components/task-row.tsx, pull openTask out of the store and mark the row:

tsx
 export const TaskRow = ({ task }: { task: Task }) => {
     const setDone = useStore((state) => state.setDone);
     const setImportant = useStore((state) => state.setImportant);
+    const openTask = useStore((state) => state.openTask);
     const moveToTrash = useStore((state) => state.moveToTrash);
tsx
         <AdwActionRow
             title={title}
             useMarkup
+            activatable
+            onActivated={() => openTask(task.id)}
             prefix={

The pane looks selectedTaskId up in tasks, and when it finds a match it renders an editor instead of the list.

In src/components/content-pane.tsx, read the new state and add the branch above the existing return:

tsx
// ...
import { TaskDetail } from "./task-detail.js";
import { TaskList } from "./task-list.js";

export const ContentPane = () => {
    const tasks = useStore((state) => state.tasks);
    const selectedTaskId = useStore((state) => state.selectedTaskId);
    const closeTask = useStore((state) => state.closeTask);
    // ...
    const task = tasks.find((candidate) => candidate.id === selectedTaskId);

    if (task) {
        return (
            <AdwToolbarView topBar={<AdwHeaderBar titleWidget={<AdwWindowTitle title={task.title} />} />}>
                <TaskDetail task={task} />
            </AdwToolbarView>
        );
    }

    return (
        // ...
    );
};

Looking the task up instead of storing it keeps the editor live: every store write produces a new task object, the pane finds it, and the fields you are about to add redraw without a subscription of their own. Going back needs no teardown either. closeTask clears selectedTaskId, the lookup fails on the next render, and the list renders.

AdwWindowTitle is the widget a header bar wants in titleWidget for plain text, and it handles the title typography Adwaita expects.

One action, many fields

Adding setTitle, setNotes, and setDue next to setDone and setImportant means a new action for every control the editor grows. Take a patch instead: an object holding whichever fields changed, merged into the task.

In src/store/tasks.ts, add updateTask to the slice type and to the creator:

ts
     setImportant: (id: string, important: boolean) => void;
+    updateTask: (id: string, fields: Partial<Pick<Task, "title" | "notes" | "due" | "listId">>) => void;
     moveToTrash: (id: string) => void;
ts
     setImportant: (id, important) => set((state) => ({ tasks: patch(state.tasks, id, { important }) })),
+    updateTask: (id, fields) => set((state) => ({ tasks: patch(state.tasks, id, fields) })),

Pick lists exactly the fields the editor may touch, so a typo like dueDate is a type error and a write to id, createdAt, or done will not compile. Partial makes each field optional, so a caller sends only what changed. setDone and setImportant stay, because they are not free-form edits, and setDone also stamps completedAt.

The form

The editor is a scroller wrapping an AdwClamp, which caps content width and centers it so the form stays readable in a wide window. AdwPreferencesGroup is the container Adwaita uses for a titled block of rows. It draws the boxed-list frame, so rows placed in it get the rounded card, the separators, and the spacing without any styling of your own.

Create src/components/task-detail.tsx:

tsx
import * as Gtk from "@gtkx/gi/gtk";
import { AdwClamp, AdwPreferencesGroup } from "@gtkx/jsx/adw";
import { GtkBox, GtkScrolledWindow } from "@gtkx/jsx/gtk";
import { useStore } from "../store/index.js";
import type { Task } from "../types.js";

export const TaskDetail = ({ task }: { task: Task }) => {
    const updateTask = useStore((state) => state.updateTask);
    const setImportant = useStore((state) => state.setImportant);

    return (
        <GtkScrolledWindow vexpand>
            <AdwClamp maximumSize={600} marginTop={24} marginBottom={24} marginStart={12} marginEnd={12}>
                <GtkBox orientation={Gtk.Orientation.VERTICAL} spacing={18}>
                    <AdwPreferencesGroup />
                </GtkBox>
            </AdwClamp>
        </GtkScrolledWindow>
    );
};

The title

AdwEntryRow is an editable row: the title is the label and text is the value. showApplyButton puts a checkmark at the end of the row that lights up once the text differs, and onApply fires when it is clicked. onEntryActivated fires on Return, so both ways of finishing an edit commit.

In src/components/task-detail.tsx, fill the first group:

tsx
                    <AdwPreferencesGroup>
                        <AdwEntryRow
                            title="Title"
                            text={task.title}
                            showApplyButton
                            onApply={(self) => updateTask(task.id, { title: self.text })}
                            onEntryActivated={(self) => updateTask(task.id, { title: self.text })}
                        />
                    </AdwPreferencesGroup>

Importance

AdwSwitchRow keeps its value in the plain GObject property active. It has no dedicated "the user flipped it" signal, so listen to the property instead: any GObject property foo emits notify::foo when it changes, and GTKX exposes that as the onNotifyFoo prop.

In src/components/task-detail.tsx, add the row under the title:

tsx
                        <AdwSwitchRow
                            title="Important"
                            active={task.important}
                            onNotifyActive={(active) => setImportant(task.id, active ?? false)}
                        />

A notify handler receives the new value first, and that value is nullable because the property is read back through the generic GObject machinery, which can return nothing. ?? false settles it, and every onNotify* handler in the app has the same shape.

The switch writes through setImportant, the same action the star uses, so flipping it also relights the star in the list.

The due date

GtkMenuButton is a button that shows a popover, and its popover slot takes that popover as JSX, so the calendar is a child of the popover and the popover belongs to the button. The button's label is the current date, formatted, and a clear button sits beside it only when there is something to clear.

In src/components/task-detail.tsx, add the due row to the group:

tsx
                        <AdwActionRow
                            title="Due"
                            suffix={
                                <GtkBox spacing={6} valign={Gtk.Align.CENTER}>
                                    {task.due ? (
                                        <GtkButton
                                            iconName="edit-clear-symbolic"
                                            cssClasses={["flat", "circular"]}
                                            accessibleLabel="Clear due date"
                                            onClicked={() => updateTask(task.id, { due: null })}
                                        />
                                    ) : null}
                                    <GtkMenuButton
                                        label={formatDue(task.due) ?? "Set date"}
                                        popover={
                                            <GtkPopover>
                                                <GtkCalendar
                                                    date={dueDate}
                                                    onDaySelected={(self) => {
                                                        const date = self.getDate();
                                                        const picked = new Date(
                                                            date.getYear(),
                                                            date.getMonth() - 1,
                                                            date.getDayOfMonth(),
                                                            18,
                                                            0,
                                                            0,
                                                        );
                                                        updateTask(task.id, { due: picked.toISOString() });
                                                    }}
                                                />
                                            </GtkPopover>
                                        }
                                    />
                                </GtkBox>
                            }
                        />

The date crosses two type systems, so it converts at both ends. The store keeps an ISO string, which survives a round trip through JSON, while GtkCalendar wants a GLib.DateTime. Build one at the top of the component, where a task with no due date gets none.

In src/components/task-detail.tsx:

tsx
    const setImportant = useStore((state) => state.setImportant);
    const dueDate = task.due ? GLib.DateTime.newFromIso8601(task.due, null) : undefined;

Coming back, self.getDate() hands you the selected day as a GLib.DateTime, and its components go into a JavaScript Date set to six in the evening local time, a friendlier default than midnight for a task.

formatDue turns the stored string into that label. The row subtitle needs the same function, so it goes in src/format.ts beside isToday and reuses the startOfDay helper already there. formatDateTime is the plainer one, for the timestamps at the bottom of the form.

In src/format.ts, add both:

ts
// ...
export const formatDue = (iso: string | null): string | null => {
    if (!iso) return null;
    const due = new Date(iso);
    const days = Math.round((startOfDay(due) - startOfDay(new Date())) / 86_400_000);
    const time = due.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
    if (days === 0) return `Today at ${time}`;
    if (days === 1) return `Tomorrow at ${time}`;
    if (days === -1) return `Yesterday at ${time}`;
    if (days < 0) return `${-days} days ago`;
    if (days < 7) return due.toLocaleDateString([], { weekday: "long" });
    return due.toLocaleDateString([], { month: "short", day: "numeric" });
};

export const formatDateTime = (iso: string | null): string => {
    if (!iso) return "Never";
    return new Date(iso).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
};

Returning null for a task with no due date lets each caller decide what "no date" looks like. The menu button falls back to "Set date", and the row to no subtitle.

In src/components/task-row.tsx, add the subtitle:

tsx
             title={title}
             useMarkup
+            subtitle={formatDue(task.due) ?? undefined}
             activatable

Pass ?? undefined rather than the null: an AdwActionRow given an empty subtitle still reserves the line, and the row grows taller than its neighbors. Given undefined, the prop is not set and the row stays single-line.

Notes

Notes are multi-line, so this is a GtkTextView. A text view keeps its content in a GtkTextBuffer, a separate object (not a widget) that holds the text, the cursor, and the undo history. GTKX exposes it as the buffer slot, and the buffer's plain text is the text prop.

In src/components/task-detail.tsx, add a block after the group:

tsx
                    <GtkBox orientation={Gtk.Orientation.VERTICAL} spacing={6}>
                        <GtkLabel halign={Gtk.Align.START} cssClasses={["heading"]}>
                            Notes
                        </GtkLabel>
                        <GtkScrolledWindow cssClasses={["card"]} heightRequest={160}>
                            <GtkTextView
                                wrapMode={Gtk.WrapMode.WORD_CHAR}
                                cssClasses={[detailNotes]}
                                buffer={
                                    <GtkTextBuffer
                                        enableUndo
                                        text={task.notes}
                                        onChanged={(buffer) =>
                                            updateTask(task.id, {
                                                notes: buffer.getText(
                                                    buffer.getStartIter(),
                                                    buffer.getEndIter(),
                                                    false,
                                                ),
                                            })
                                        }
                                    />
                                }
                            />
                        </GtkScrolledWindow>
                    </GtkBox>

text makes the buffer controlled: onChanged writes each edit to the store, and the store feeds text back. GTKX skips a write when the buffer already holds that value, so a keystroke round-trip leaves the cursor and the undo history alone. getText reads the range as a start and end iterator, the trailing false leaves out invisible markup, and enableUndo gives the notes field its own Ctrl+Z and Ctrl+Shift+Z.

The card style class gives the scroller the framed look Adwaita uses for a content box. You supply the padding yourself.

In src/styles.ts, add the class beside listDot:

ts
// ...
export const detailNotes = css`
    padding: 6px;
    min-height: 160px;
`;

css returns a generated class name, which is why it goes into cssClasses as a value rather than a string literal. The CSS guide covers the details.

Timestamps

The last group is read-only: createdAt is stamped by addTask, completedAt by setDone. The property style class is Adwaita's convention for a row whose subtitle is the value, swapping the emphasis so the value reads larger than the label.

In src/components/task-detail.tsx, add the final group:

tsx
                    <AdwPreferencesGroup>
                        <AdwActionRow
                            cssClasses={["property"]}
                            title="Created"
                            subtitle={formatDateTime(task.createdAt)}
                        />
                        {task.completedAt ? (
                            <AdwActionRow
                                cssClasses={["property"]}
                                title="Completed"
                                subtitle={formatDateTime(task.completedAt)}
                            />
                        ) : null}
                    </AdwPreferencesGroup>

Switching tasks cleanly

The editor holds state React knows nothing about: where the cursor sits in the title entry, the buffer's undo stack, which month the calendar shows. Go back, open a different task, and React sees the same TaskDetail in the same position and just updates its props. The widgets survive, and so does all that state.

In src/components/content-pane.tsx, give the editor a key:

tsx
-                <TaskDetail task={task} />
+                <TaskDetail key={task.id} task={task} />

A changed key tells React to throw the old tree away and build a new one, so a new task gets fresh widgets: a cursor at the start, an empty undo history, and a calendar opened on its own month. This is the same tool the task list uses to reset its scroll position when the sidebar selection changes.

The detail header bar

The editor needs a way out, and the commands you would otherwise scroll to reach belong at the top. In the header bar's start and end slots, add a back button, the star, and delete.

In src/components/content-pane.tsx, grow the branch:

tsx
    if (task) {
        return (
            <AdwToolbarView
                topBar={
                    <AdwHeaderBar
                        titleWidget={<AdwWindowTitle title={task.title} />}
                        start={
                            <GtkButton
                                iconName="go-previous-symbolic"
                                tooltipText="Back"
                                onClicked={closeTask}
                            />
                        }
                        end={
                            <>
                                <GtkToggleButton
                                    iconName={task.important ? "starred-symbolic" : "non-starred-symbolic"}
                                    active={task.important}
                                    tooltipText="Important"
                                    onToggled={(self) => setImportant(task.id, self.active)}
                                />
                                <GtkButton
                                    iconName="user-trash-symbolic"
                                    tooltipText="Delete"
                                    onClicked={() => moveToTrash(task.id)}
                                />
                            </>
                        }
                    />
                }
            >
                <TaskDetail key={task.id} task={task} />
            </AdwToolbarView>
        );
    }

Read setImportant and moveToTrash from the store at the top of the component, the same way the pane already reads closeTask. Deleting from here leaves the editor open over a task that is now in the trash. Leave that gap for now: Deleting Without Fear gives every delete an undo toast and a confirmation, and closes the editor along the way.

The header title comes from the task, so applying a new title updates the header too.

Run it

Save the files. The window on your desktop already has the editor in it.

  1. Click any task row. The content pane becomes a form with Title, Important, and Due at the top, a Notes box, and a Created timestamp at the bottom. The header bar shows the task's title with a back arrow on the left.
  2. Change the title and press Enter. The header title updates immediately. Click the back arrow and the list is showing again, with the new title on the row.
  3. Open a task and click Set date. A calendar drops down; pick today. The button reads Today at 6:00 PM, a clear button appears beside it, and going back puts the same text under the row's title. Open the task again and click the clear button: the subtitle disappears from the row entirely rather than leaving a blank gap.
  4. Type into Notes and press Ctrl+Z: the last thing you typed is undone. Go back, open a different task, and the notes box holds that task's notes with none of the previous undo history. Press Ctrl+Z there and nothing happens.

The store still persists on every write, so what the editor sets is on disk before you go anywhere. After setting a due date, read it back:

bash
jq '.state.tasks[] | select(.due != null) | {title, due}' ~/.local/share/com.gtkx.tutorial/tasks.json

The ISO string is the one the calendar produced.

Checkpoint

The finished editor. src/components/task-detail.tsx:

tsx
import * as GLib from "@gtkx/gi/glib";
import * as Gtk from "@gtkx/gi/gtk";
import { AdwActionRow, AdwClamp, AdwEntryRow, AdwPreferencesGroup, AdwSwitchRow } from "@gtkx/jsx/adw";
import {
    GtkBox,
    GtkButton,
    GtkCalendar,
    GtkLabel,
    GtkMenuButton,
    GtkPopover,
    GtkScrolledWindow,
    GtkTextBuffer,
    GtkTextView,
} from "@gtkx/jsx/gtk";
import { formatDateTime, formatDue } from "../format.js";
import { useStore } from "../store/index.js";
import { detailNotes } from "../styles.js";
import type { Task } from "../types.js";

export const TaskDetail = ({ task }: { task: Task }) => {
    const updateTask = useStore((state) => state.updateTask);
    const setImportant = useStore((state) => state.setImportant);
    const dueDate = task.due ? GLib.DateTime.newFromIso8601(task.due, null) : undefined;

    return (
        <GtkScrolledWindow vexpand>
            <AdwClamp maximumSize={600} marginTop={24} marginBottom={24} marginStart={12} marginEnd={12}>
                <GtkBox orientation={Gtk.Orientation.VERTICAL} spacing={18}>
                    <AdwPreferencesGroup>
                        <AdwEntryRow
                            title="Title"
                            text={task.title}
                            showApplyButton
                            onApply={(self) => updateTask(task.id, { title: self.text })}
                            onEntryActivated={(self) => updateTask(task.id, { title: self.text })}
                        />
                        <AdwSwitchRow
                            title="Important"
                            active={task.important}
                            onNotifyActive={(active) => setImportant(task.id, active ?? false)}
                        />
                        <AdwActionRow
                            title="Due"
                            suffix={
                                <GtkBox spacing={6} valign={Gtk.Align.CENTER}>
                                    {task.due ? (
                                        <GtkButton
                                            iconName="edit-clear-symbolic"
                                            cssClasses={["flat", "circular"]}
                                            accessibleLabel="Clear due date"
                                            onClicked={() => updateTask(task.id, { due: null })}
                                        />
                                    ) : null}
                                    <GtkMenuButton
                                        label={formatDue(task.due) ?? "Set date"}
                                        popover={
                                            <GtkPopover>
                                                <GtkCalendar
                                                    date={dueDate}
                                                    onDaySelected={(self) => {
                                                        const date = self.getDate();
                                                        const picked = new Date(
                                                            date.getYear(),
                                                            date.getMonth() - 1,
                                                            date.getDayOfMonth(),
                                                            18,
                                                            0,
                                                            0,
                                                        );
                                                        updateTask(task.id, { due: picked.toISOString() });
                                                    }}
                                                />
                                            </GtkPopover>
                                        }
                                    />
                                </GtkBox>
                            }
                        />
                    </AdwPreferencesGroup>

                    <GtkBox orientation={Gtk.Orientation.VERTICAL} spacing={6}>
                        <GtkLabel halign={Gtk.Align.START} cssClasses={["heading"]}>
                            Notes
                        </GtkLabel>
                        <GtkScrolledWindow cssClasses={["card"]} heightRequest={160}>
                            <GtkTextView
                                wrapMode={Gtk.WrapMode.WORD_CHAR}
                                cssClasses={[detailNotes]}
                                buffer={
                                    <GtkTextBuffer
                                        enableUndo
                                        text={task.notes}
                                        onChanged={(buffer) =>
                                            updateTask(task.id, {
                                                notes: buffer.getText(
                                                    buffer.getStartIter(),
                                                    buffer.getEndIter(),
                                                    false,
                                                ),
                                            })
                                        }
                                    />
                                }
                            />
                        </GtkScrolledWindow>
                    </GtkBox>

                    <AdwPreferencesGroup>
                        <AdwActionRow
                            cssClasses={["property"]}
                            title="Created"
                            subtitle={formatDateTime(task.createdAt)}
                        />
                        {task.completedAt ? (
                            <AdwActionRow
                                cssClasses={["property"]}
                                title="Completed"
                                subtitle={formatDateTime(task.completedAt)}
                            />
                        ) : null}
                    </AdwPreferencesGroup>
                </GtkBox>
            </AdwClamp>
        </GtkScrolledWindow>
    );
};

Next

Menus, Accelerators, and Shortcuts turns the commands scattered across these buttons into GActions, puts them in a primary menu, and binds them to keys.

Released under the MPL-2.0 License.