Skip to content

Commit d2e24eb

Browse files
mjfzefhemel
andauthored
Add a dropdown picker for custom task states (#1900)
* Add a dropdown picker for custom task states Add a dropdown picker for custom task states that appears as a small triangle next to the `[state]` allowing to quickly switch between defined states with autocomplete. States marked as done receive strikethrough styling on the task text. Each state can be styled individually using the `data-task-state` attribute, for example: ```space-style [data-task-state=PLANNED] .sb-task-state { color: black; background-color: orange; } ``` Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Keep the current task state visible while the dropdown picker is open Keep the current task state visible while the dropdown picker is open by disabling built-in filtering and handling it manually — when the state text exactly matches a known state all options are shown, otherwise results are filtered by what the user has typed so far. Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> * Added on-hover completion styling * Fix: Show all defined task states in the dropdown when clicking the picker on an unrecognized state instead of showing an empty menu Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> --------- Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz> Co-authored-by: Zef Hemel <zef@zef.me>
1 parent ee2a3ed commit d2e24eb

File tree

5 files changed

+138
-14
lines changed

5 files changed

+138
-14
lines changed

client/codemirror/clean.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@ export function cleanModePlugins(client: Client) {
5656
void client.dispatchClickEvent(clickEvent);
5757
},
5858
getView: () => client.editorView,
59+
doneStates: (() => {
60+
const taskStates = client.config.get("taskStates", {});
61+
const done = new Set<string>();
62+
for (const [name, spec] of Object.entries(taskStates) as [
63+
string,
64+
any,
65+
][]) {
66+
if (spec.done) done.add(name);
67+
}
68+
return done;
69+
})(),
5970
}),
6071
listBulletPlugin(),
6172
tablePlugin(client),

client/codemirror/task.ts

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { syntaxTree } from "@codemirror/language";
2+
import { startCompletion, completionStatus } from "@codemirror/autocomplete";
23
import { Decoration, type EditorView, WidgetType } from "@codemirror/view";
34
import type { NodeType } from "@lezer/common";
45
import { decoratorStateField, isCursorInRange } from "./util.ts";
@@ -73,12 +74,56 @@ class CheckboxWidget extends WidgetType {
7374
}
7475
}
7576

77+
/**
78+
* Tiny widget placed right after an extended task's `]`.
79+
* Click places cursor inside `[...]` and triggers autocomplete.
80+
*/
81+
class TaskDropdownWidget extends WidgetType {
82+
constructor(
83+
// Absolute position of the `[` (start of state content inside brackets)
84+
readonly stateFrom: number,
85+
// Absolute position of the `]` (end of TaskState node)
86+
readonly stateTo: number,
87+
readonly getView: () => EditorView | null,
88+
) {
89+
super();
90+
}
91+
92+
toDOM(): HTMLElement {
93+
const span = document.createElement("span");
94+
span.className = "sb-task-dropdown";
95+
span.textContent = "\u25BE"; // Black Down-Pointing Small Triangle
96+
span.addEventListener("mousedown", (e) => {
97+
e.preventDefault();
98+
e.stopPropagation();
99+
});
100+
span.addEventListener("click", (e) => {
101+
e.stopPropagation();
102+
const view = this.getView();
103+
if (!view) return;
104+
const cursorPos = this.stateTo - 1;
105+
view.dispatch({
106+
selection: { anchor: cursorPos },
107+
});
108+
view.focus();
109+
startCompletion(view);
110+
});
111+
return span;
112+
}
113+
114+
eq(other: TaskDropdownWidget): boolean {
115+
return this.stateFrom === other.stateFrom && this.stateTo === other.stateTo;
116+
}
117+
}
118+
76119
export function taskListPlugin({
77120
onCheckboxClick,
78121
getView,
122+
doneStates,
79123
}: {
80124
onCheckboxClick: (pos: number) => void;
81125
getView: () => EditorView | null;
126+
doneStates?: Set<string>;
82127
}) {
83128
return decoratorStateField((state) => {
84129
const widgets: any[] = [];
@@ -87,33 +132,62 @@ export function taskListPlugin({
87132
if (type.name !== "Task") return;
88133
// true/false if this is a checkbox, undefined when it's a custom-status task
89134
let checkboxStatus: boolean | undefined;
90-
// Iterate inside the task node to find the checkbox
135+
// Track TaskState end position for strikethrough start
136+
let taskStateEnd = -1;
137+
91138
node.toTree().iterate({
92139
enter: (ref) => iterateInner(ref.type, ref.from, ref.to),
93140
});
141+
94142
if (checkboxStatus === true) {
95-
widgets.push(
96-
Decoration.mark({
97-
tagName: "span",
98-
class: "cm-task-checked",
99-
}).range(from, to),
100-
);
143+
// Skip whitespace after TaskState
144+
let strikeFrom = taskStateEnd !== -1 ? taskStateEnd : from;
145+
while (
146+
strikeFrom < to &&
147+
" \t".includes(state.sliceDoc(strikeFrom, strikeFrom + 1))
148+
) {
149+
strikeFrom++;
150+
}
151+
if (strikeFrom < to) {
152+
widgets.push(
153+
Decoration.mark({
154+
tagName: "span",
155+
class: "cm-task-checked",
156+
}).range(strikeFrom, to),
157+
);
158+
}
101159
}
102160

103161
function iterateInner(type: NodeType, nfrom: number, nto: number) {
104162
if (type.name !== "TaskState") return;
105-
if (isCursorInRange(state, [from + nfrom, from + nto])) return;
163+
taskStateEnd = from + nto;
106164
const checkbox = state.sliceDoc(from + nfrom, from + nto);
107-
// Checkbox is checked if it has a 'x' in between the []
108165
if (checkbox === "[x]" || checkbox === "[X]") {
109166
checkboxStatus = true;
110167
} else if (checkbox === "[ ]") {
111168
checkboxStatus = false;
112169
}
113170
if (checkboxStatus === undefined) {
114-
// Not replacing it with a widget
171+
const stateText = checkbox.slice(1, -1);
172+
if (doneStates?.has(stateText)) {
173+
checkboxStatus = true;
174+
}
175+
// Mark the full TaskState node
176+
widgets.push(
177+
Decoration.mark({
178+
attributes: { "data-task-state": stateText },
179+
}).range(from + nfrom, from + nto),
180+
);
181+
// Always show dropdown
182+
const absTo = from + nto;
183+
const dec = Decoration.widget({
184+
widget: new TaskDropdownWidget(from + nfrom, absTo, getView),
185+
side: 1,
186+
});
187+
widgets.push(dec.range(absTo));
115188
return;
116189
}
190+
if (isCursorInRange(state, [from + nfrom, from + nto])) return;
117191
const dec = Decoration.replace({
118192
widget: new CheckboxWidget(
119193
checkboxStatus,

client/styles/colors.scss

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,9 +225,21 @@
225225
color: var(--editor-completion-detail-color);
226226
}
227227

228-
li[aria-selected] .cm-completionDetail {
228+
li[aria-selected] .cm-completionDetail,
229+
li:hover .cm-completionDetail {
229230
color: var(--editor-completion-detail-selected-color);
230231
}
232+
233+
ul:hover li[aria-selected] {
234+
background: transparent;
235+
color: var(--modal-color);
236+
}
237+
238+
ul li:hover,
239+
ul li[aria-selected]:hover {
240+
background: #17c;
241+
color: white;
242+
}
231243
}
232244

233245
.cm-list-bullet::after {

client/styles/editor.scss

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,16 @@
349349
font-size: 91%;
350350
}
351351

352+
.sb-task-dropdown {
353+
cursor: pointer;
354+
opacity: 0.4;
355+
user-select: none;
356+
357+
&:hover {
358+
opacity: 1;
359+
}
360+
}
361+
352362
.sb-task-deadline {
353363
background-color: rgba(22, 22, 22, 0.07);
354364
}

plugs/index/complete.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,32 @@ function humanReadableSchemaType(type: any): string {
5050
* Task state completion
5151
*/
5252
export async function completeTaskState(completeEvent: CompleteEvent) {
53-
const taskMatch = /([-*]\s+\[)([^[\]]+)$/.exec(completeEvent.linePrefix);
53+
const taskMatch = /([-*]\s+\[)([^[\]]*)$/.exec(completeEvent.linePrefix);
5454
if (!taskMatch) {
5555
return null;
5656
}
5757
const allStates = Object.keys(await config.get("taskStates", {}));
58+
const typed = taskMatch[2];
59+
60+
let options: string[];
61+
if (!typed) {
62+
// Nothing typed — show all
63+
options = allStates;
64+
} else if (allStates.includes(typed)) {
65+
// Exact match (dropdown click on existing state) — show all
66+
options = allStates;
67+
} else {
68+
// Partial typing — filter by prefix, fall back to all if nothing matches
69+
const filtered = allStates.filter((s) =>
70+
s.toLowerCase().startsWith(typed.toLowerCase()),
71+
);
72+
options = filtered.length > 0 ? filtered : allStates;
73+
}
5874

5975
return {
60-
from: completeEvent.pos - taskMatch[2].length,
61-
options: allStates.map((state) => ({
76+
from: completeEvent.pos - typed.length,
77+
filter: false,
78+
options: options.map((state) => ({
6279
label: state,
6380
})),
6481
};

0 commit comments

Comments
 (0)