From 20b374f575121321acaa7af9208313f2d710a57b Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 19 Jan 2025 18:17:09 -0500 Subject: [PATCH] Add new API for settings UI 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. --- CHANGELOG.md | 1 + src/settings.ts | 19 +++++++--- src/ui/setting-group.ts | 22 ++++++----- src/ui/settings-builder.ts | 75 +++++++++++++++++++++++++------------- 4 files changed, 77 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88af7d8..7f67b13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/settings.ts b/src/settings.ts index 67793e2..a49c264 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -90,12 +90,15 @@ export const settings = /* @__PURE__ */ (() => { if (typeof prop === "string") { return helpers.get(prop) || setMap(helpers, prop, cached( () => { - 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} + )) + })) } }}) @@ -143,9 +146,15 @@ export const settings = /* @__PURE__ */ (() => { settingsLoaded.set(true) } } + + function throwUnlessLoaded() { + if (!settingsLoaded) throw new Error("Settings not loaded yet") + } + function withUntil(ob: T, until: () => Yielding): T & UntilMethod { return Object.assign(ob, {"uneventful.until": until}) } + function withRuleAndEdit(ob: T) { return Object.assign(ob, { /** diff --git a/src/ui/setting-group.ts b/src/ui/setting-group.ts index 68d0df3..8d1e0fc 100644 --- a/src/ui/setting-group.ts +++ b/src/ui/setting-group.ts @@ -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 +export function group(): SettingGroup export function group(owner: T): SettingGroup -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 extends Setting implements FieldParent { +export class SettingGroup extends Setting implements FieldParent { readonly detailsEl: HTMLDetailsElement; - constructor(readonly parent?: T, public containerEl: HTMLElement = (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()); } @@ -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})) diff --git a/src/ui/settings-builder.ts b/src/ui/settings-builder.ts index 4d0d29e..9b387e3 100644 --- a/src/ui/settings-builder.ts +++ b/src/ui/settings-builder.ts @@ -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 = (ctx: T) => unknown; @@ -15,7 +18,10 @@ export function applyFeatures(thing: T, ...features: Feature[]) { } /** @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 { containerEl: containerEl, field(parentEl?: HTMLElement) { return new FieldBuilder(this, parentEl); }, @@ -27,7 +33,7 @@ export function settingsBuilder(containerEl: HTMLElement = useSettingsTab().cont export function field(): FieldBuilder; export function field(owner: T): FieldBuilder; export function field(owner?: FieldParent) { - return new FieldBuilder(owner || useSettingsTab()); + return new FieldBuilder(owner); } /** @category Settings UI */ @@ -37,33 +43,50 @@ export interface SettingsProvider extends Component { /** @category Settings UI */ export function useSettingsTab(owner?: SettingsProvider & Partial) { - 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[]) { return applyFeatures(this, ...features); } @@ -94,7 +117,7 @@ export interface FieldParent { /** @category Settings UI */ export class FieldBuilder extends Setting { - constructor(public builder: T, parentEl = builder.containerEl ) { + constructor(public builder = settingsBuilder() as T, parentEl = builder.containerEl ) { super(parentEl); } end() {