Skip to content

Commit

Permalink
Add new API for settings UI
Browse files Browse the repository at this point in the history
The new API lets you just use field() or group() blocks at
module level or in a settings.rule or anywhere else you like.
Any fields or groups added while the tab isn't shown will remain
in the settings when the tab is hidden again.
  • Loading branch information
pjeby committed Jan 19, 2025
1 parent f77e62c commit 20b374f
Show file tree
Hide file tree
Showing 4 changed files with 77 additions and 40 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

### 0.0.25 (unreleased)

- New settings APIs: `settings` and `settingsTab`. (The older APIs are still available, but share their implementation with the new APIs.) Among other features, automatic transparent support for `onExternalSettingsChange()` has been added, meaning that as long as you're using setting subscriptions your app will automatically apply any new settings when using Obsidian Sync or other tools that update your plugin's `data.json`.
- Add `@register` decorator that lets you run your Obsidian components' `.onload()` methods in an Uneventful job, so you don't have to `this.register(root.start(() => {}).end)` in every component.
- Fixed: `LayoutSetting.onSet()` was passing the wrong arguments to its callback when setting was tied to a specific layout item (via `.of()` or at construction time). If you were relying on this behavior, you may need to add an extra (ignored) initial argument to your callback.
- As of this version, [Uneventful](https://uneventful.js.org/) 0.0.10 is required as a peerDependency, and the old preact/wonka code has been removed.
Expand Down
19 changes: 14 additions & 5 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,15 @@ export const settings = /* @__PURE__ */ (() => {
if (typeof prop === "string") {
return helpers.get(prop) || setMap(helpers, prop, cached<JSON>(
() => {
if (settingsLoaded()) return cookedSettings()[prop]
throw new Error("Settings not loaded yet")
throwUnlessLoaded()
return cookedSettings()[prop]
}
).withSet(v => rawSettings.set(JSON.stringify(
{...JSON.parse(rawSettings()), [prop]: v}
))))
).withSet(v => {
throwUnlessLoaded()
rawSettings.set(JSON.stringify(
{...JSON.parse(rawSettings()), [prop]: v}
))
}))
}
}})

Expand Down Expand Up @@ -143,9 +146,15 @@ export const settings = /* @__PURE__ */ (() => {
settingsLoaded.set(true)
}
}

function throwUnlessLoaded() {
if (!settingsLoaded) throw new Error("Settings not loaded yet")
}

function withUntil<T extends object, R>(ob: T, until: () => Yielding<R>): T & UntilMethod<R> {
return Object.assign(ob, {"uneventful.until": until})
}

function withRuleAndEdit<T extends object>(ob: T) {
return Object.assign(ob, {
/**
Expand Down
22 changes: 13 additions & 9 deletions src/ui/setting-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,32 @@ import { LocalObject } from "../localStorage.ts";
import { obsidian as o } from "../obsidian.ts";
import { Service, the, app } from "../services.ts";
import { cached, rule, value } from "uneventful/signals";
import { Feature, applyFeatures, FieldBuilder, FieldParent, useSettingsTab, SettingsTabBuilder } from "./settings-builder.ts";
import { Feature, applyFeatures, FieldBuilder, FieldParent, settingsBuilder } from "./settings-builder.ts";
import groupStyle from "scss:./setting-group.scss";
import { addOn } from "../add-ons.ts";

/** @category Settings UI */
export function group(): SettingGroup<SettingsTabBuilder>
export function group(): SettingGroup<FieldParent>
export function group<T extends FieldParent>(owner: T): SettingGroup<T>
export function group(owner?: FieldParent) {
return new SettingGroup(owner || useSettingsTab());
export function group(owner: FieldParent = settingsBuilder()) {
return new SettingGroup(owner);
}

/** @category Settings UI */
class SettingGroup<T extends FieldParent> extends Setting implements FieldParent {
export class SettingGroup<T extends FieldParent> extends Setting implements FieldParent {

readonly detailsEl: HTMLDetailsElement;

constructor(readonly parent?: T, public containerEl: HTMLElement = (<FieldParent>parent || useSettingsTab()).containerEl) {
constructor(readonly parent = settingsBuilder() as T, public containerEl: HTMLElement = parent.containerEl) {
if (!containerEl.matchParent("details.ophidian-settings-group")) {
// We are a root group, make sure the container has the style
groupStyleEl(containerEl)
}
const detailsEl = containerEl.createEl("details", "ophidian-settings-group");
const summaryEl = detailsEl.createEl("summary", "ophidian-settings-group");
super(summaryEl);
this.setHeading();
this.containerEl = (this.detailsEl = detailsEl).createDiv();
if (!containerEl.parentElement.matchParent("details.ophidian-settings-group")) {
detailsEl.createEl("style", {text: groupStyle});
}
// prevent closing group on click
this.controlEl.addEventListener("click", e => e.preventDefault());
}
Expand Down Expand Up @@ -62,3 +64,5 @@ export class SettingGroupState extends Service {
get(key: string, dflt = false) { return cached(() => this.data()[key] ?? dflt)(); }
set(key: string, value: boolean) { return this.storage.modify(v => { v[key] = value; }); }
}

const groupStyleEl = addOn((el: HTMLElement) => el.createEl("style", {text: groupStyle}))
75 changes: 49 additions & 26 deletions src/ui/settings-builder.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { Component, PluginSettingTab, Setting } from "obsidian";
import { obsidian as o } from "../obsidian.ts";
import { SettingsService } from "../plugin-settings.ts";
import { Useful, getContext, onLoad, use, app } from "../services.ts";
import { must, root } from "uneventful";
import { peek, rule, value } from "uneventful/signals"
import { Useful, getContext } from "../services.ts";
import { peek, rule, until, value } from "uneventful/signals"
import { settings } from "../settings.ts";
import { service } from "uneventful/shared";
import { app, plugin } from "../plugin.ts";

const tabEl = /* @__PURE__ */ createDiv("vertical-tab-content")

/** @category Settings UI */
export type Feature<T> = (ctx: T) => unknown;
Expand All @@ -15,7 +18,10 @@ export function applyFeatures<T>(thing: T, ...features: Feature<T>[]) {
}

/** @category Settings UI */
export function settingsBuilder(containerEl: HTMLElement = useSettingsTab().containerEl) {
export function settingsBuilder(containerEl: HTMLElement = tabEl) {
// If we're building stuff at the root of the default container, make sure
// the settings tab will load and get registered.
if (containerEl === tabEl) loadSettingsTab()
return <FieldParent>{
containerEl: containerEl,
field(parentEl?: HTMLElement) { return new FieldBuilder(this, parentEl); },
Expand All @@ -27,7 +33,7 @@ export function settingsBuilder(containerEl: HTMLElement = useSettingsTab().cont
export function field(): FieldBuilder<SettingsTabBuilder>;
export function field<T extends FieldParent>(owner: T): FieldBuilder<T>;
export function field(owner?: FieldParent) {
return new FieldBuilder(owner || useSettingsTab());
return new FieldBuilder(owner);
}

/** @category Settings UI */
Expand All @@ -37,33 +43,50 @@ export interface SettingsProvider extends Component {

/** @category Settings UI */
export function useSettingsTab(owner?: SettingsProvider & Partial<Useful>) {
return getContext(owner)(SettingsTabBuilder).addProvider(owner) as SettingsTabBuilder;
return settingsTab().addProvider(owner);
}

/** @category Settings UI */
const loadSettingsTab = /* @__PURE__ */ service(function *() {
yield *until(settings)
return settingsTab()
})

const settingsTab = /* @__PURE__ */ service(() => {
if (!plugin) throw new Error("Plugin not created/registered yet")
const tab = new SettingsTabBuilder(app, plugin)
tab.containerEl = tabEl
settings.rule(() => {
tab.plugin.addSettingTab(tab)
rule(() => {
if (!tab.isOpen()) return;
const children = Array.from(tab.containerEl.childNodes)
const c = new o.Component;
c.load();
peek(() => tab.providers.forEach(p => p._loaded && p.showSettings(c)));
return () => { c.unload(); tab.containerEl.setChildrenInPlace(children); }
});
})
return tab
})

/**
* The Settings Tab class
*
* This is mostly an implementation detail; you will generally want to use
* {@link settingsTab}, {@link useSettingsTab}, {@link field}, and/or
* {@link group} rather than directly interacting with this.
*
* @category Settings UI
*/
export class SettingsTabBuilder extends PluginSettingTab implements Useful, FieldParent {

plugin = use(o.Plugin)
use = use.this;
plugin = plugin
use = getContext();

isOpen = value(false);
providers: SettingsProvider[] = []

constructor() {
super(app, use(o.Plugin));
this.plugin.register(root.start(() => {
must(use(SettingsService).once(() => {
onLoad(this.plugin, () => this.plugin.addSettingTab(this));
}));
rule(() => {
if (!this.isOpen()) return;
const c = new o.Component;
c.load();
peek(() => this.providers.forEach(p => p._loaded && p.showSettings(c)));
return () => { c.unload(); this.clear(); }
});
}).end);
}
static "use.me" = settingsTab

with(...features: Feature<this>[]) { return applyFeatures(this, ...features); }

Expand Down Expand Up @@ -94,7 +117,7 @@ export interface FieldParent {

/** @category Settings UI */
export class FieldBuilder<T extends FieldParent> extends Setting {
constructor(public builder: T, parentEl = builder.containerEl ) {
constructor(public builder = settingsBuilder() as T, parentEl = builder.containerEl ) {
super(parentEl);
}
end() {
Expand Down

0 comments on commit 20b374f

Please sign in to comment.