diff --git a/ivette/src/dome/src/renderer/data/json.ts b/ivette/src/dome/src/renderer/data/json.ts index ba0be2fc66fcef5b3a37e5e436633d1f4ebfba3f..2e2ebe87b2de5fc4ad4c8ced72592c3aa2ce7a04 100644 --- a/ivette/src/dome/src/renderer/data/json.ts +++ b/ivette/src/dome/src/renderer/data/json.ts @@ -11,7 +11,7 @@ import { DEVEL } from 'dome/system'; export type json = - undefined | null | number | string | json[] | { [key: string]: json } + undefined | null | boolean | number | string | json[] | { [key: string]: json } /** Parse without _revivals_. diff --git a/ivette/src/dome/src/renderer/data/settings.ts b/ivette/src/dome/src/renderer/data/settings.ts new file mode 100644 index 0000000000000000000000000000000000000000..aeddf5c47c9ecd7621e3588adb6f37c76ff1ca24 --- /dev/null +++ b/ivette/src/dome/src/renderer/data/settings.ts @@ -0,0 +1,320 @@ +// -------------------------------------------------------------------------- +// --- States +// -------------------------------------------------------------------------- + +/** + Typed States & Settings + @packageDocumentation + @module dome/data/settings +*/ + +import React from 'react'; +import { ipcRenderer } from 'electron'; +import { debounce } from 'lodash'; +import * as Dome from 'dome'; +import isEqual from 'react-fast-compare'; +import { DEVEL } from 'dome/misc/system'; +import * as JSON from './json'; +import type { State } from './states'; + +// -------------------------------------------------------------------------- +// --- Settings +// -------------------------------------------------------------------------- + +/** @internal */ +interface Settings<A> { + decoder: JSON.Loose<A>; + encoder: JSON.Encoder<A>; + defaultValue: A; +} + +/** + Global settings. + */ +export class GlobalSettings<A> { + name: string; + decoder: JSON.Loose<A>; + encoder: JSON.Encoder<A>; + defaultValue: A; + constructor( + name: string, + decoder: JSON.Loose<A>, + encoder: JSON.Encoder<A>, + defaultValue: A, + ) { + this.name = name; + this.decoder = decoder; + this.encoder = encoder; + this.defaultValue = defaultValue; + } +} + +// -------------------------------------------------------------------------- +// --- Smart Constructors +// -------------------------------------------------------------------------- + +/** Boolean settings with `true` default. */ +export class GTrue extends GlobalSettings<boolean> { + constructor(name: string) { super(name, JSON.jBoolean, JSON.identity, true); } +} + +/** Boolean settings with `false` default. */ +export class GFalse extends GlobalSettings<boolean> { + constructor(name: string) { super(name, JSON.jBoolean, JSON.identity, false); } +} + +/** Numeric settings (default is zero unless specified). */ +export class GNumber extends GlobalSettings<number> { + constructor(name: string, defaultValue: number = 0) { + super(name, JSON.jNumber, JSON.identity, defaultValue); + }; +} + +/** String settings (default is `""` unless specified). */ +export class GString extends GlobalSettings<string> { + constructor(name: string, defaultValue: string = '') { + super(name, JSON.jString, JSON.identity, defaultValue); + }; +} + +/** Smart constructor for optional (JSON serializable) data. */ +export class GOption<A extends JSON.json> + extends GlobalSettings<A | undefined> +{ + constructor(name: string, encoder: JSON.Loose<A>, defaultValue?: A) { + super(name, encoder, JSON.identity, defaultValue); + } +} + +/** Smart constructor for (JSON serializable) data with default. */ +export class GDefault<A extends JSON.json> extends GlobalSettings<A> { + constructor(name: string, encoder: JSON.Loose<A>, defaultValue: A) { + super(name, encoder, JSON.identity, defaultValue); + } +} + +/** Smart constructor for object (JSON serializable) data. */ +export class GObject<A extends JSON.json> extends GlobalSettings<A> { + constructor(name: string, fields: JSON.Props<A>, defaultValue: A) { + super(name, JSON.jObject(fields), JSON.identity, defaultValue); + } +} + +// -------------------------------------------------------------------------- +// --- Generic Settings (private) +// -------------------------------------------------------------------------- + +type patch = { key: string, value: JSON.json }; +type driver = { evt: string, ipc: string, broadcast: boolean }; + +class Driver { + + readonly evt: string; // broadcast event + readonly broadcast: boolean; // settings broadcast + readonly store: Map<string, JSON.json> = new Map(); + readonly diffs: Map<string, JSON.json> = new Map(); + readonly fire: (() => void) & { flush: () => void; cancel: () => void }; + + constructor({ evt, ipc, broadcast }: driver) { + this.evt = evt; + this.broadcast = broadcast; + // --- Update Events + this.fire = debounce(() => { + const m = this.diffs; + if (m.size > 0) { + const patches: patch[] = []; + m.forEach((value, key) => { + patches.push({ key, value }); + }); + m.clear(); + ipcRenderer.send(ipc, patches); + } + }, 100); + // --- Init Events + ipcRenderer.on('dome.ipc.settings.defaults', () => { + this.fire.cancel(); + this.store.clear(); + this.diffs.clear(); + Dome.emit(this.evt); + }); + // --- Broadcast Events + if (this.broadcast) { + ipcRenderer.on('dome.ipc.settings.update', (_sender, patches: patch[]) => { + const m = this.store; + const d = this.diffs; + patches.forEach(({ key, value }) => { + // Don't cancel local updates + if (!d.has(key)) { + if (value === null) + m.delete(key); + else + m.set(key, value); + } + }); + Dome.emit('dome.settings'); + }); + } + // --- Closing Events + ipcRenderer.on('dome.ipc.closing', () => { + this.fire(); + this.fire.flush(); + }); + } + + // --- Load Data + + load(key: string | undefined): JSON.json { + return key === undefined ? undefined : this.store.get(key); + } + + // --- Save Data + + save(key: string | undefined, data: JSON.json) { + if (key === undefined) return; + if (data === undefined) { + this.store.delete(key); + this.diffs.set(key, null); + } else { + this.store.set(key, data); + this.diffs.set(key, data); + } + if (this.broadcast) Dome.emit(this.evt); + this.fire(); + } + +} + +// -------------------------------------------------------------------------- +// --- Generic Settings Hook +// -------------------------------------------------------------------------- + +const keys = new Set<string>(); + +function useSettings<A>( + S: Settings<A>, + D: Driver, + K?: string, +): State<A> { + // Check for unique key + React.useEffect(() => { + if (K) { + if (keys.has(K) && DEVEL) + console.error('[Dome.settings] Duplicate key', K); + else { + keys.add(K); + return () => { keys.delete(K); }; + } + } + return undefined; + }); + // Load value + const loader = () => ( + JSON.jCatch(S.decoder, S.defaultValue)(D.load(K)) + ); + // Local state + const [value, setValue] = React.useState<A>(loader); + // Broadcast + React.useEffect(() => { + if (K) { + const callback = () => setValue(loader()); + return () => Dome.off(D.evt, callback); + } + return undefined; + }); + // Updates + const updateValue = React.useCallback((newValue: A) => { + if (!isEqual(value, newValue)) { + setValue(newValue); + if (K) D.save(K, S.encoder(newValue)); + } + }, [S, D, K]); + return [value, updateValue]; +} + +// -------------------------------------------------------------------------- +// --- Window Settings +// -------------------------------------------------------------------------- + +const WindowDriver = new Driver({ + evt: 'dome.settings.window', + ipc: 'dome.ipc.settings.window', + broadcast: false, +}); + +/** + Returns the current value of the settings (default for undefined key). + */ +export function getWindowSettings<A>( + key: string | undefined, + decoder: JSON.Loose<A>, + defaultValue: A, +) { + return key ? + JSON.jCatch(decoder, defaultValue)(WindowDriver.load(key)) + : undefined; +} + +/** + Updates the current value of the settings (on defined key). + Most settings are subtypes of `JSON` and do not require any specific + encoder. If you have some, simply use it before updating the settings. + See [[useWindowSettings]] and [[useWindowsettingsdata]]. + */ +export function setWindowSettings( + key: string | undefined, + value: JSON.json, +) { + if (key) WindowDriver.save(key, value); +} + +/** + Returns a local state that is saved back to the local window user settings. + Local window settings are stored in the `.<appName>` file of the working + directory, or in the closest one in parent directories, if any. + */ +export function useWindowSettings<A extends JSON.json>( + key: string | undefined, + decoder: JSON.Loose<A>, + defaultValue: A, +) { + return useSettings({ + decoder, + encoder: JSON.identity, + defaultValue + }, WindowDriver, key); +} + +/** Same as [[useWindowSettings]] with a specific encoder. */ +export function useWindowSettingsData<A>( + key: string | undefined, + decoder: JSON.Loose<A>, + encoder: JSON.Encoder<A>, + defaultValue: A, +) { + return useSettings({ + decoder, + encoder, + defaultValue + }, WindowDriver, key); +} + +// -------------------------------------------------------------------------- +// --- Global Settings +// -------------------------------------------------------------------------- + +const GlobalDriver = new Driver({ + evt: 'dome.settings.global', + ipc: 'dome.ipc.settings.window', + broadcast: true, +}); + +/** + Returns a global state, which is synchronized among all windows, and saved + back in the global user settings. The global user settings file is located in + the usual place for the application with respect to the underlying system. + */ +export function useGlobalSettings<A>(S: GlobalSettings<A>) { + return useSettings(S, GlobalDriver, S.name); +} + +// -------------------------------------------------------------------------- diff --git a/ivette/src/dome/src/renderer/data/states.ts b/ivette/src/dome/src/renderer/data/states.ts index 3e07ef8cb453d111db4d9e2d5e47c48e5ae83c12..84ff5d6a811304cf7b0318e971dc1fe5f5f4927a 100644 --- a/ivette/src/dome/src/renderer/data/states.ts +++ b/ivette/src/dome/src/renderer/data/states.ts @@ -3,7 +3,7 @@ // -------------------------------------------------------------------------- /** - Typed States & Settings + Local & Global States @packageDocumentation @module dome/data/states */ @@ -11,13 +11,12 @@ import React from 'react'; import Emitter from 'events'; import isEqual from 'react-fast-compare'; -import { DEVEL } from 'dome/misc/system'; -import * as Dome from 'dome'; -import * as JSON from './json'; -const UPDATE = 'dome.states.update'; +// -------------------------------------------------------------------------- +// --- State utilities +// -------------------------------------------------------------------------- -/** State interface. */ +/** Alias to `[state,setState]` returned values*/ export type State<A> = [A, (update: A) => void]; /** State field of an object state. */ @@ -58,6 +57,12 @@ export function local<A>(init: A): State<A> { return [ref.current, (v) => ref.current = v]; } +// -------------------------------------------------------------------------- +// --- Global States +// -------------------------------------------------------------------------- + +const UPDATE = 'dome.states.update'; + /** Cross-component State. */ export class GlobalState<A> { @@ -107,217 +112,3 @@ export function useGlobalState<A>(s: GlobalState<A>): State<A> { }; // -------------------------------------------------------------------------- -// --- Settings -// -------------------------------------------------------------------------- - -/** - Generic interface to Window and Global Settings. - To be used with [[useSettings]] with instances of its derived classes, - typically [[WindowSettings]] and [[GlobalSettings]]. You should never have - to implement a Settings class on your own. - - All setting values are identified with - an untyped `dataKey: string`, that can be dynamically modified - for each component. Hence, two components might share both datakeys - and settings. - - When several components share the same setting `dataKey` the behavior will be - different depending on the situation: - - for Window Settings, each component in each window retains its own - setting value, although the last modified value from _any_ of them will be - saved and used for any further initial value; - - for Global Settings, all components synchronize to the last modified value - from any component of any window. - - Type safety is ensured by safe JSON encoders and decoders, however, they - might fail at runtime, causing settings value to be initialized to their - fallback and not to be saved nor synchronized. - This is not harmful but annoying. - - To mitigate this effect, each instance of a Settings class has its - own, private, unique symbol that we call its « role ». A given `dataKey` - shall always be used with the same « role » otherwise it is discarded, - and an error message is logged when in DEVEL mode. - */ -export abstract class Settings<A> { - - private static keyRoles = new Map<string, symbol>(); - - private readonly role: symbol; - protected readonly decoder: JSON.Safe<A>; - protected readonly encoder: JSON.Encoder<A>; - - /** - Encoders shall be protected against exception. - Use [[dome/data/json.jTry]] and [[dome/data/json.jCatch]] in case of uncertainty. - Decoders are automatically protected internally to the Settings class. - @param role Debugging name of instance roles (each instance has its unique - role, though) - @param decoder JSON decoder for the setting values - @param encoder JSON encoder for the setting values - @param fallback If provided, used to automatically protect your encoders - against exceptions. - */ - constructor( - role: string, - decoder: JSON.Safe<A>, - encoder: JSON.Encoder<A>, - fallback?: A, - ) { - this.role = Symbol(role); - this.encoder = encoder; - this.decoder = - fallback !== undefined ? JSON.jCatch(decoder, fallback) : decoder; - } - - /** - Returns identity if the data key is only - used with the same setting instance. - Otherwise, returns `undefined`. - */ - validateKey(dataKey?: string): string | undefined { - if (dataKey === undefined) return undefined; - const rq = this.role; - const rk = Settings.keyRoles.get(dataKey); - if (rk === undefined) { - Settings.keyRoles.set(dataKey, rq); - } else { - if (rk !== rq) { - if (DEVEL) console.error( - `[Dome.settings] Key ${dataKey} used with incompatible roles`, rk, rq, - ); - return undefined; - } - } - return dataKey; - } - - /** @internal */ - abstract loadData(key: string): JSON.json; - - /** @internal */ - abstract saveData(key: string, data: JSON.json): void; - - /** @internal */ - abstract event: string; - - /** Returns the current setting value for the provided data key. You shall - only use validated keys otherwise you might fallback to default values. */ - loadValue(dataKey?: string) { - return this.decoder(dataKey ? this.loadData(dataKey) : undefined) - } - - /** Push the new setting value for the provided data key. - You shall only use validated keys otherwise further loads - might fail and fallback to defaults. */ - saveValue(dataKey: string, value: A) { - try { this.saveData(dataKey, this.encoder(value)); } - catch (err) { - if (DEVEL) console.error( - '[Dome.settings] Error while encoding value', - dataKey, value, err, - ); - } - } - -} - -/** - Generic React Hook to be used with any kind of [[Settings]]. - You may share `dataKey` between components, or change it dynamically. - However, a given data key shall always be used for the same Setting instance. - See [[Settings]] documentation for details. - @param S The instance settings to be used. - @param dataKey Identifies which value in the settings to be used. - */ -export function useSettings<A>( - S: Settings<A>, - dataKey?: string, -): [A, (update: A) => void] { - - const theKey = React.useMemo(() => S.validateKey(dataKey), [S, dataKey]); - const [value, setValue] = React.useState<A>(() => S.loadValue(theKey)); - - React.useEffect(() => { - if (theKey) { - const callback = () => setValue(S.loadValue(theKey)); - Dome.on(S.event, callback); - return () => Dome.off(S.event, callback); - } - return undefined; - }); - - const updateValue = React.useCallback((update: A) => { - if (!isEqual(value, update)) { - setValue(update); - if (theKey) S.saveValue(theKey, update); - } - }, [S, theKey]); - - return [value, updateValue]; - -} - -/** Window Settings for non-JSON data. - In most situations, you can use [[WindowSettings]] instead. - You can use a [[dome/data/json.Loose]] decoder for optional values. */ -export class WindowSettingsData<A> extends Settings<A> { - - constructor( - role: string, - decoder: JSON.Safe<A>, - encoder: JSON.Encoder<A>, - fallback?: A, - ) { - super(role, decoder, encoder, fallback); - } - - event = 'dome.defaults'; - loadData(key: string) { return Dome.getWindowSetting(key) as JSON.json; } - saveData(key: string, data: JSON.json) { Dome.setWindowSetting(key, data); } - -} - -/** Global Settings for non-JSON data. - In most situations, you can use [[WindowSettings]] instead. - You can use a [[dome/data/json.Loose]] decoder for optional values. */ -export class GlobalSettingsData<A> extends Settings<A> { - - constructor( - role: string, - decoder: JSON.Safe<A>, - encoder: JSON.Encoder<A>, - fallback?: A, - ) { - super(role, decoder, encoder, fallback); - } - - event = 'dome.settings'; - loadData(key: string) { return Dome.getGlobalSetting(key) as JSON.json; } - saveData(key: string, data: JSON.json) { Dome.setGlobalSetting(key, data); } - -} - -/** Window Settings. - For non-JSON data, use [[WindowSettingsData]] instead. - You can use a [[dome/data/json.Loose]] decoder for optional values. */ -export class WindowSettings<A extends JSON.json> extends WindowSettingsData<A> { - - constructor(role: string, decoder: JSON.Safe<A>, fallback?: A) { - super(role, decoder, JSON.identity, fallback); - } - -} - -/** Global Settings. - For non-JSON data, use [[WindowSettingsData]] instead. - You can use a [[dome/data/json.Loose]] decoder for optional values. */ -export class GlobalSettings<A extends JSON.json> extends GlobalSettingsData<A> { - - constructor(role: string, decoder: JSON.Safe<A>, fallback?: A) { - super(role, decoder, JSON.identity, fallback); - } - -} - -// -------------------------------------------------------------------------- diff --git a/ivette/src/dome/src/renderer/dome.js b/ivette/src/dome/src/renderer/dome.js index 322a76d8bc3242c7d2631da72db37257d2602649..face33d62a13c994b370e894f179d2c9c5a23f16 100644 --- a/ivette/src/dome/src/renderer/dome.js +++ b/ivette/src/dome/src/renderer/dome.js @@ -388,167 +388,10 @@ export function popupMenu( items, callback ) } // -------------------------------------------------------------------------- -// --- Settings +// --- Closing // -------------------------------------------------------------------------- -var globalSettings = new Map(); -var globalPatches = new Map(); - -var windowSettings = new Map(); -var windowPatches = new Map(); - -const initSetting = - (m, data) => _.forEach(data,(value,key) => m.set(key,value)); - -// initial values => synchronized event -const syncSettings = () => { - const fullSettings = ipcRenderer.sendSync('dome.ipc.settings.sync'); - initSetting( globalSettings, fullSettings.globals ); - initSetting( windowSettings, fullSettings.settings ); -}; - -const readSetting = ( local, key, defaultValue ) => { - const store = local ? windowSettings : globalSettings; - const value = store.get(key); - return value === undefined ? defaultValue : value ; -}; - -const writeSetting = ( local, key, value ) => { - const store = local ? windowSettings : globalSettings; - const patches = local ? windowPatches : globalPatches; - if (value === undefined) { - store.delete(key); - patches.set(key,null); - } else { - store.set(key,value); - patches.set(key,value); - } - if (local) { - fireSaveSettings(); - } else { - emitter.emit('dome.settings'); - fireSaveGlobals(); - } -}; - -const flushPatches = (m) => { - if (m.size > 0) { - const args = []; - m.forEach((value,key) => { - args.push({ key, value }); - }); - m.clear(); - return args; - } - return undefined; -}; - -const fireSaveSettings = _.debounce( - () => { - const args = flushPatches(windowPatches); - args && ipcRenderer.send( 'dome.ipc.settings.window', args ) ; - }, 100 -); - -const fireSaveGlobals = _.debounce( - () => { - const args = flushPatches(globalPatches); - args && ipcRenderer.send( 'dome.ipc.settings.global', args ) ; - }, 100 -); - -ipcRenderer.on('dome.ipc.closing', (_evt) => { - fireSaveSettings(); - fireSaveSettings.flush(); - fireSaveGlobals(); - fireSaveGlobals.flush(); - System.doExit(); -}); - -/** @event 'dome.settings' - @description Emitted when the global settings have been updated. */ - -/** @event 'dome.defaults' - @description Emitted when the window settings have re-initialized. */ - -ipcRenderer.on('dome.ipc.settings.defaults',(sender) => { - fireSaveSettings.cancel(); - fireSaveGlobals.cancel(); - windowPatches.clear(); - globalPatches.clear(); - windowSettings.clear(); - globalSettings.clear(); - emitter.emit('dome.settings'); - emitter.emit('dome.defaults'); -}); - -ipcRenderer.on('dome.ipc.settings.update',(sender,patches) => { - patches.forEach(({ key, value }) => { - // Don't cancel local updates - if (!globalPatches.has(key)) { - if (value === null) - globalSettings.delete(key); - else - globalSettings.set(key,value); - } - }); - emitter.emit('dome.settings'); -}); - -/** - @summary Get value from local window (persistent) settings. - @param {string} [key] - User's Setting Key (`'dome.*'` are reserved keys) - @param {any} [defaultValue] - default value if the key is not present - @return {any} associated value of object or `undefined`. - @description - This settings are local to the current window, but persistently - saved in the user's home directory.<br/> - For global application settings, use `getGlobal()` instead. -*/ -export function getWindowSetting( key, defaultValue ) { - return key ? readSetting( true, key , defaultValue ) : defaultValue ; -} - -/** @summary Set value into local window (persistent) settings. - @param {string} [key] to store the data - @param {any} value associated value or object - @description - This settings are local to the current window, but persistently - saved in the user's home directory.<br/> - For global application settings, use `setGlobal()` instead. -*/ -export function setWindowSetting( key , value ) { - key && writeSetting( true, key, value ); -} - -/** - @summary Get value from application (persistent) settings. - @param {string} key User's Setting Key (`'dome.*'` are reserved keys) - @param {any} [defaultValue] - default value if the key is not present - @return {any} associated value of object or `undefined`. - @description - These settings are global to the application and persistently - saved in the user's home directory.<br/> - For local window settings, use `get()` instead. -*/ -export function getGlobalSetting( key, defaultValue ) { - return key ? readSetting( false, key , defaultValue ) : defaultValue ; -} - -/** @summary Set value into application (persistent) settings. - @param {string} key to store the data - @param {any} value associated value or object - @description - These settings are global to the current window, but persistently - saved in the user's home directory. Updated values are broadcasted - in batch to all other windows, - which in turn receive a `'dome.settings'` - event for synchronizing.<br/> - For local window settings, use `set()` instead. -*/ -export function setGlobalSetting( key , value ) { - writeSetting( false, key, value ); -} +ipcRenderer.on('dome.ipc.closing', System.doExit); // -------------------------------------------------------------------------- // --- Focus Management