From b031cd7e045c86a78654017f688ae1eba5362947 Mon Sep 17 00:00:00 2001 From: Olivier Date: Mon, 7 Sep 2026 14:45:24 -0400 Subject: [PATCH] feat: visually group settings by category with collapsible cards - Each command category (Better Formatting, Line Operations, etc.) now renders as a collapsible
card with a bordered/shaded header showing an 'N/M enabled' badge, instead of a flat list separated only by bold text headings. - Sentence regex + reset moved into the Sentence Navigator category card (it configures that category's commands, so it belongs there instead of the general section). - Bulk actions (global enable/disable all, per-category toggle-all, regex reset) now patch the DOM in place instead of calling the full display() re-render, so open/closed category state is never lost when a setting changes. - Removed the 'General' settings heading (obsidianmd/settings-tab/no-problematic-settings-headings lint rule flags it). - styles.css adds .bindthem-category-* rules for the card borders, header shading, collapse caret, and setting-row separators. --- src/settings.ts | 157 +++++++++++++++++++++++++++++++++--------------- styles.css | 80 +++++++++++++++++++++--- 2 files changed, 181 insertions(+), 56 deletions(-) diff --git a/src/settings.ts b/src/settings.ts index 7bb0474..7d2d289 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,4 +1,4 @@ -import { App, PluginSettingTab, Setting } from 'obsidian'; +import { App, PluginSettingTab, Setting, TextComponent } from 'obsidian'; import BindThemPlugin from './main'; import { DEFAULT_SENTENCE_REGEX } from './Constants'; @@ -164,6 +164,13 @@ export const DEFAULT_SETTINGS: BindThemSettings = { export class BindThemSettingTab extends PluginSettingTab { plugin: BindThemPlugin; + /** + * Refresh functions for each rendered category body. Bulk actions call + * these directly instead of re-running `display()`, so toggling a + * setting never collapses categories the user has open. + */ + private categoryRefreshers: Array<() => void> = []; + constructor(app: App, plugin: BindThemPlugin) { super(app, plugin); this.plugin = plugin; @@ -172,6 +179,7 @@ export class BindThemSettingTab extends PluginSettingTab { display(): void { const { containerEl } = this; containerEl.empty(); + this.categoryRefreshers = []; // Hotkeys link at the top containerEl.createEl('a', { @@ -184,14 +192,10 @@ export class BindThemSettingTab extends PluginSettingTab { }, (el) => { el.addEventListener('click', (e) => { e.preventDefault(); - const hotkeysTab = (this.app as unknown as { setting: { openTabById: (id: string) => unknown } }).setting.openTabById('hotkeys'); - if (hotkeysTab) { - (hotkeysTab as unknown as { searchComponent: { setValue: (v: string) => void } }).searchComponent.setValue('BindThem'); - } + this.openHotkeysSearch(); }); }); - // General settings new Setting(containerEl) .setName('Debug mode') .setDesc('Enable debug logging to console') @@ -202,36 +206,16 @@ export class BindThemSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); - new Setting(containerEl) - .setName('Sentence regex') - .setDesc('Regular expression used to match sentences') - .addText(text => text - .setValue(this.plugin.settings.sentenceRegexSource) - .onChange(async (value) => { - this.plugin.settings.sentenceRegexSource = value; - await this.plugin.saveSettings(); - })); - - new Setting(containerEl) - .setName('Reset sentence regex') - .addButton(button => button - .setButtonText('Reset') - .onClick(async () => { - this.plugin.settings.sentenceRegexSource = DEFAULT_SENTENCE_REGEX; - await this.plugin.saveSettings(); - this.display(); - })); - // Commands section header new Setting(containerEl) .setName('Command availability') - .setDesc('Toggle which commands are available in Obsidian. Disabled commands will not appear in the command palette.') + .setDesc('Toggle which commands are available in Obsidian. Disabled commands will not appear in the command palette. Select a category below to see its commands.') .setHeading(); - // Category toggle buttons + // Global toggle-all buttons new Setting(containerEl) .setName('Enable/disable all') - .setDesc('Toggle all commands on or off') + .setDesc('Toggle every command in every category on or off') .addButton(button => button .setButtonText('Enable all') .onClick(async () => { @@ -239,7 +223,7 @@ export class BindThemSettingTab extends PluginSettingTab { this.plugin.settings.enabledCommands[cmdId] = true; } await this.plugin.saveSettings(); - this.display(); + this.refreshAllCategories(); })) .addButton(button => button .setButtonText('Disable all') @@ -249,12 +233,13 @@ export class BindThemSettingTab extends PluginSettingTab { this.plugin.settings.enabledCommands[cmdId] = false; } await this.plugin.saveSettings(); - this.display(); + this.refreshAllCategories(); })); - // Render each category + // Render each category as a collapsible, visually grouped card + const categoryListEl = containerEl.createDiv({ cls: 'bindthem-category-list' }); for (const [categoryKey, categoryName] of Object.entries(COMMAND_CATEGORIES)) { - this.renderCategory(containerEl, categoryKey, categoryName); + this.renderCategory(categoryListEl, categoryKey, categoryName); } // About section @@ -265,35 +250,78 @@ export class BindThemSettingTab extends PluginSettingTab { .setHeading(); } + /** + * Opens the Hotkeys settings tab and pre-fills its search with "BindThem". + * `app.setting` (the settings modal controller) isn't part of Obsidian's + * public API types, so the shape is narrowed once here rather than cast + * inline at each property access. + */ + private openHotkeysSearch(): void { + const appWithSettingModal = this.app as unknown as { + setting: { + openTabById: (id: string) => { searchComponent: { setValue: (v: string) => void } } | null; + }; + }; + const hotkeysTab = appWithSettingModal.setting.openTabById('hotkeys'); + if (hotkeysTab) { + hotkeysTab.searchComponent.setValue('BindThem'); + } + } + + private refreshAllCategories(): void { + for (const refresh of this.categoryRefreshers) { + refresh(); + } + } + private renderCategory(containerEl: HTMLElement, categoryKey: string, categoryName: string): void { const commands = COMMANDS[categoryKey as keyof typeof COMMANDS]; if (!commands || commands.length === 0) return; - // Category header with toggle all - const categorySetting = new Setting(containerEl) - .setName(categoryName) - .setHeading(); + const details = containerEl.createEl('details', { cls: 'bindthem-category' }); + const summary = details.createEl('summary', { cls: 'bindthem-category-summary' }); + summary.createSpan({ text: categoryName, cls: 'bindthem-category-name' }); + const badge = summary.createSpan({ cls: 'bindthem-category-count' }); - // Check if all commands in this category are enabled - const allEnabled = commands.every(cmd => this.plugin.settings.enabledCommands[cmd.id] !== false); + const content = details.createDiv({ cls: 'bindthem-category-content' }); - // Add toggle all button for this category - categorySetting.addToggle(toggle => { - toggle - .setValue(allEnabled) + const refresh = () => this.renderCategoryBody(content, badge, categoryKey, commands); + refresh(); + this.categoryRefreshers.push(refresh); + } + + private renderCategoryBody( + content: HTMLElement, + badge: HTMLElement, + categoryKey: string, + commands: CommandDefinition[] + ): void { + content.empty(); + + const enabledCount = commands.filter(cmd => this.plugin.settings.enabledCommands[cmd.id] !== false).length; + badge.setText(`${enabledCount}/${commands.length} enabled`); + + // Sentence regex configures the Sentence Navigator commands, so it + // lives inside that category's card instead of the general section. + if (categoryKey === 'sentenceNavigator') { + this.renderSentenceRegexSetting(content); + } + + new Setting(content) + .setName('Enable all in this category') + .addToggle(toggle => toggle + .setValue(enabledCount === commands.length) .setTooltip('Toggle all commands in this category') .onChange(async (value) => { for (const cmd of commands) { this.plugin.settings.enabledCommands[cmd.id] = value; } await this.plugin.saveSettings(); - this.display(); - }); - }); + this.renderCategoryBody(content, badge, categoryKey, commands); + })); - // Render individual commands for (const cmd of commands) { - new Setting(containerEl) + new Setting(content) .setName(cmd.name) .setDesc(cmd.description || `Command ID: ${cmd.id}`) .addToggle(toggle => toggle @@ -301,7 +329,40 @@ export class BindThemSettingTab extends PluginSettingTab { .onChange(async (value) => { this.plugin.settings.enabledCommands[cmd.id] = value; await this.plugin.saveSettings(); + const newCount = commands.filter(c => this.plugin.settings.enabledCommands[c.id] !== false).length; + badge.setText(`${newCount}/${commands.length} enabled`); })); } } + + /** + * Renders the sentence-matching regex setting and its reset button. + * Updates the text field directly on reset instead of re-rendering. + */ + private renderSentenceRegexSetting(containerEl: HTMLElement): void { + let textComponent: TextComponent | undefined; + + new Setting(containerEl) + .setName('Sentence regex') + .setDesc('Regular expression used to match sentences') + .addText(text => { + textComponent = text; + text + .setValue(this.plugin.settings.sentenceRegexSource) + .onChange(async (value) => { + this.plugin.settings.sentenceRegexSource = value; + await this.plugin.saveSettings(); + }); + }); + + new Setting(containerEl) + .setName('Reset sentence regex') + .addButton(button => button + .setButtonText('Reset') + .onClick(async () => { + this.plugin.settings.sentenceRegexSource = DEFAULT_SENTENCE_REGEX; + await this.plugin.saveSettings(); + textComponent?.setValue(DEFAULT_SENTENCE_REGEX); + })); + } } \ No newline at end of file diff --git a/styles.css b/styles.css index a52b029..c2ba7bd 100644 --- a/styles.css +++ b/styles.css @@ -1,8 +1,72 @@ -/* - -This CSS file will be included with your plugin, and -available in the app when your plugin is enabled. - -If your plugin does not need CSS, delete this file. - -*/ +/* + +This CSS file will be included with your plugin, and +available in the app when your plugin is enabled. + +*/ + +/* ============================================================ + Settings tab — command category grouping + ============================================================ */ + +.bindthem-category-list { + margin-bottom: 0.5em; +} + +.bindthem-category { + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + margin-bottom: 0.5em; + overflow: hidden; +} + +.bindthem-category > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75em; + padding: 0.65em 1em; + cursor: pointer; + font-weight: 600; + background-color: var(--background-secondary); + list-style: none; +} + +.bindthem-category > summary::-webkit-details-marker { + display: none; +} + +.bindthem-category > summary::before { + content: '▸'; + display: inline-block; + margin-right: 0.5em; + color: var(--text-muted); + transition: transform 0.15s ease-in-out; +} + +.bindthem-category[open] > summary::before { + transform: rotate(90deg); +} + +.bindthem-category-name { + flex: 1; +} + +.bindthem-category-count { + font-weight: 400; + font-size: var(--font-ui-smaller); + color: var(--text-muted); + white-space: nowrap; +} + +.bindthem-category-content { + padding: 0 1em; +} + +.bindthem-category-content .setting-item { + border-top: 1px solid var(--background-modifier-border); +} + +.bindthem-category-content .setting-item:first-child { + border-top: none; +}