feat: Add a simple mode to the visual card editor (#2588)

This commit is contained in:
Dermot Duffy
2026-07-21 19:13:29 -07:00
committed by GitHub
parent b6e1de5999
commit e29c0826cf
56 changed files with 1722 additions and 353 deletions
+20
View File
@@ -11,6 +11,7 @@ import {
upgradeConfig,
} from '../../config/management';
import { setProfiles } from '../../config/profiles/set-profiles';
import type { EditorMode } from '../../config/schema/editor';
import { profilesSchema, type ProfileType } from '../../config/schema/profiles';
import { configDefaults } from '../../config/schema/types';
import type {
@@ -27,6 +28,7 @@ import { getFolderID } from '../../utils/folder';
import { applyConfigChanges } from './form-data';
import type { FormsInput } from './forms-controller';
import type { EditorIntent } from './intents';
import { deriveEditorMode, getEditorMode } from './mode';
import type { FormRequestOptions } from './schema/registry';
import { getEditorCameraTitle, getEditorFolderTitle } from './titles';
@@ -69,6 +71,9 @@ export class EditorController implements ReactiveController {
// The parsed configuration profiles (empty when unset or invalid).
private _profiles: ProfileType[] = [];
// Which editor the configuration asks for, or suits.
private _editorMode: EditorMode = 'simple';
constructor(host: EditorControllerHost) {
this._host = host;
host.addController(this);
@@ -103,6 +108,7 @@ export class EditorController implements ReactiveController {
private _applyConfig(config: RawAdvancedCameraCardConfig): void {
this._config = config;
this._configUpgradeable = isConfigUpgradeable(config);
this._editorMode = getEditorMode(config);
// The defaults are rebuilt from scratch so that removing (or breaking) a
// profile also removes its adjusted defaults.
@@ -129,6 +135,16 @@ export class EditorController implements ReactiveController {
if (Array.isArray(overrides) && overrides.length) {
notices.push({ type: 'info', message: localize('config.overrides.info') });
}
// The simple editor can be asked for by a configuration that sets more than
// it shows, so tell the user those settings are still in effect.
if (
this._config &&
this._editorMode === 'simple' &&
deriveEditorMode(this._config) === 'full'
) {
notices.push({ type: 'info', message: localize('editor.simple_coverage') });
}
return notices;
}
@@ -136,6 +152,10 @@ export class EditorController implements ReactiveController {
return this._config;
}
public getEditorMode(): EditorMode {
return this._editorMode;
}
public getDefaults(): RawAdvancedCameraCardConfig {
return this._defaults;
}
+11 -11
View File
@@ -1,6 +1,7 @@
import { DOCS_URL } from '../../const';
import type { HAFormSchema } from '../../ha/types';
import { isFormFieldSchema, type HAFormSchema } from '../../ha/types';
import { getFormContainerPath, stripArrayIndices } from './paths';
import type { ConfigPath } from './types';
// Documentation links keyed by configuration path (without array indices).
// Sections and expandable groups with an entry here render a documentation
@@ -51,7 +52,6 @@ const DOC_LINKS: Record<string, string> = {
'media_viewer.display': 'configuration/media-viewer?id=display',
menu: 'configuration/menu',
'menu.buttons': 'configuration/menu?id=buttons',
options: 'configuration/README',
performance: 'configuration/performance',
'performance.features': 'configuration/performance?id=features',
'performance.style': 'configuration/performance?id=style',
@@ -77,7 +77,7 @@ const DOC_LINKS: Record<string, string> = {
* @returns A full documentation URL, or null if the path has no documentation
* link.
*/
export const getDocURL = (path: (string | number)[]): string | null => {
export const getDocURL = (path: ConfigPath): string | null => {
const docPath = DOC_LINKS[stripArrayIndices(path).join('.')];
return docPath ? `${DOCS_URL}/#/${docPath}` : null;
};
@@ -90,22 +90,22 @@ export const getDocURL = (path: (string | number)[]): string | null => {
* @returns The configuration path to link to, or null for no link.
*/
export const getDocLinkPath = (
basePath: (string | number)[],
basePath: ConfigPath,
schema: HAFormSchema,
options?: { path?: string[] },
): (string | number)[] | null => {
): ConfigPath | null => {
// Documentation links attach to containers (sections/groups), never to
// individual fields.
if ('selector' in schema) {
if (isFormFieldSchema(schema)) {
return null;
}
// A nameless group (an editor-only grouping such as "Engines") carries an
// explicit `docPath`; a named group derives its path from the configuration
// path.
return schema.docPath
? schema.docPath.split('.')
: schema.name
? [...basePath, ...getFormContainerPath(options), schema.name]
: null;
const docPath = schema.type === 'expandable' ? schema.docPath : null;
return (
docPath ??
(schema.name ? [...basePath, ...getFormContainerPath(options), schema.name] : null)
);
};
+28 -4
View File
@@ -7,9 +7,14 @@ import {
setConfigValue,
} from '../../config/management';
import type { RawAdvancedCameraCardConfig } from '../../config/types';
import type { HAFormSchema, HAFormSelectorSchema } from '../../ha/types';
import {
isFormFieldSchema,
type HAFormSchema,
type HAFormSelectorSchema,
} from '../../ha/types';
import { isRecord } from '../../utils/basic';
import {
findBinding,
isComputedFieldBinding,
type ConfigChange,
type ConfigPath,
@@ -30,7 +35,7 @@ export const forEachFieldRecursively = (
): void => {
const recurse = (items: readonly HAFormSchema[], prefix: string[]): void => {
for (const item of items) {
if ('selector' in item) {
if (isFormFieldSchema(item)) {
callback([...prefix, item.name], item);
} else {
// A nameless expandable is a visual-only grouping, so it is not added
@@ -42,8 +47,27 @@ export const forEachFieldRecursively = (
recurse(schema, []);
};
const findBinding = (form: EditorForm, formPath: string[]): FieldBinding | undefined =>
form.bindings?.find((binding) => isEqual(binding.formPath, formPath));
/**
* Get the configuration paths a form's fields address: those a binding names,
* or, for an unbound field, its position within the form beneath the form's
* base path.
* @param form The form.
* @returns The absolute configuration paths, in field order.
*/
export const getFormConfigPaths = (form: EditorForm): ConfigPath[] => {
const paths: ConfigPath[] = [];
forEachFieldRecursively(form.schema, (path) => {
const binding = findBinding(form, path);
if (!binding) {
paths.push([...form.basePath, ...path]);
} else if (isComputedFieldBinding(binding)) {
paths.push(...binding.configPaths);
} else {
paths.push(binding.configPath);
}
});
return paths;
};
// The value to show for a bound field. A computed binding works its own value
// out; otherwise the configured value is shown, and only a missing value falls
+28 -13
View File
@@ -1,42 +1,57 @@
import type { HAFormSchema } from '../../ha/types';
import { isFormFieldSchema, type HAFormSchema } from '../../ha/types';
import { localize } from '../../localize/localize';
import { getFormContainerPath, stripArrayIndices } from './paths';
import {
findBinding,
isComputedFieldBinding,
type ConfigPath,
type EditorForm,
} from './types';
/**
* Get the localization key for a configuration path.
* @param path The configuration path segments.
* @returns The localization key.
*/
export const getLocalizationKeyForPath = (path: (string | number)[]): string =>
export const getLocalizationKeyForPath = (path: ConfigPath): string =>
['config', ...stripArrayIndices(path)].join('.');
/**
* Compute the label for a form field. Intended for use as an `ha-form`
* `computeLabel` callback, bound to the path of the form's data within the
* configuration. Container nodes are labelled by their explicit title, never
* `computeLabel` callback. A field is named for the setting it edits, which for
* a bound field is wherever that setting is stored rather than where the field
* sits in the form. Container nodes are labelled by their explicit title, never
* by configuration path (their headers already render the title).
* @param basePath The path of the form's data within the configuration.
* @param form The form the field belongs to.
* @param schema The field's schema.
* @param options Path context provided by `ha-form` for fields nested inside
* containers.
* @returns A localized label.
*/
export const computeFormLabel = (
basePath: (string | number)[],
form: EditorForm,
schema: HAFormSchema,
options?: { path?: string[] },
): string => {
if (schema.label !== undefined) {
return schema.label;
}
if (!('selector' in schema)) {
return schema.title ?? schema.name ?? '';
if (!isFormFieldSchema(schema)) {
// Only an expandable has a title: a grid lays out fields that are labelled
// individually, and shows nothing of its own.
return (schema.type === 'expandable' ? schema.title : null) ?? schema.name ?? '';
}
const formPath = [...getFormContainerPath(options), schema.name];
const binding = findBinding(form, formPath);
// A field standing for more than one setting has no single name to take, and
// so carries its own label.
return localize(
getLocalizationKeyForPath([
...basePath,
...getFormContainerPath(options),
schema.name,
]),
getLocalizationKeyForPath(
binding && !isComputedFieldBinding(binding)
? binding.configPath
: [...form.basePath, ...formPath],
),
);
};
@@ -173,7 +173,7 @@ export class FormsController {
schema: HAFormSchema,
_data?: unknown,
options?: { path?: string[] },
) => computeFormLabel(form.basePath, schema, options),
) => computeFormLabel(form, schema, options),
// Caution: `ha-form` types the helper as a string, but interpolates it
// into a Lit template, so a template renders as-is; should a future Home
@@ -10,7 +10,10 @@ import type { ConfigChange, ConfigPath } from './types';
type KeyboardShortcutsControllerHost = ReactiveControllerHost & EventTarget;
const FORM_REQUEST: FormRequest = { kind: 'section', name: 'view.keyboard_shortcuts' };
const FORM_REQUEST: FormRequest = {
kind: 'full-section',
name: 'view.keyboard_shortcuts',
};
/**
* The keyboard shortcuts panel: the settings that apply to all the shortcuts,
+74
View File
@@ -0,0 +1,74 @@
import { editorConfigSchema, type EditorMode } from '../../config/schema/editor';
import type { RawAdvancedCameraCardConfig } from '../../config/types';
import { isRecord } from '../../utils/basic';
import { getFormConfigPaths } from './form-data';
import { stripArrayIndices } from './paths';
import { getFullEditorForms } from './schema/full';
import {
getSimpleCameraForms,
getSimpleMenuForms,
getSimpleTopLevelForms,
} from './schema/simple';
import type { EditorForm } from './types';
// The configuration paths a set of forms shows, without array indices so that
// the third camera's title is the camera title field.
const getShownPaths = (forms: EditorForm[]): Set<string> =>
new Set(
forms.flatMap(getFormConfigPaths).map((path) => stripArrayIndices(path).join('.')),
);
// What the full editor shows and the simple editor does not. Configuration
// outside this is either something the simple editor shows too, or something
// neither shows (`elements`, `debug`, anything the card does not recognise at
// all), which the full editor would be no better at showing.
const getFullOnlyPaths = (): Set<string> => {
const simplePaths = getShownPaths([
...getSimpleCameraForms(0),
...getSimpleMenuForms(),
...getSimpleTopLevelForms(),
]);
return new Set(
[...getShownPaths(getFullEditorForms())].filter((path) => !simplePaths.has(path)),
);
};
// The paths of every value the configuration sets. Lists contribute the paths
// of their items' contents, without the position of the item.
const getSetPaths = (value: unknown, prefix: string[] = []): string[] => {
if (Array.isArray(value)) {
return value.flatMap((item) => getSetPaths(item, prefix));
}
if (isRecord(value)) {
return Object.entries(value).flatMap(([key, item]) =>
getSetPaths(item, [...prefix, key]),
);
}
return [prefix.join('.')];
};
/**
* Work out which editor to show for a configuration that does not say. The full
* editor is shown when the configuration sets something it shows and the simple
* editor does not, so that opening the simple editor never hides a setting the
* user could otherwise have seen.
* @param config The raw (possibly invalid) configuration.
* @returns The editor mode.
*/
export const deriveEditorMode = (config: RawAdvancedCameraCardConfig): EditorMode => {
const fullOnlyPaths = getFullOnlyPaths();
return getSetPaths(config).some((path) => fullOnlyPaths.has(path)) ? 'full' : 'simple';
};
/**
* Get which editor to show for a configuration: what it asks for, or, if it
* does not ask (or asks for something that is not an editor), what suits it.
* @param config The raw (possibly invalid) configuration.
* @returns The editor mode.
*/
export const getEditorMode = (config: RawAdvancedCameraCardConfig): EditorMode => {
const editor = editorConfigSchema.safeParse(config['editor']);
return editor.success && editor.data.mode
? editor.data.mode
: deriveEditorMode(config);
};
+3 -1
View File
@@ -1,3 +1,5 @@
import type { ConfigPath } from './types';
/**
* Convert the container path `ha-form` passes to `computeLabel`/`computeHelper`
* into configuration order. `ha-form` builds the path by appending each
@@ -16,7 +18,7 @@ export const getFormContainerPath = (options?: { path?: string[] }): string[] =>
* @param path The configuration path segments.
* @returns The path segments without array indices.
*/
export const stripArrayIndices = (path: (string | number)[]): string[] =>
export const stripArrayIndices = (path: ConfigPath): string[] =>
path.filter(
(segment): segment is string =>
typeof segment === 'string' && !/^\d+$/.test(segment),
+32 -23
View File
@@ -70,6 +70,35 @@ const getMediaLayoutSchema = (): HAFormExpandableSchema => ({
],
});
/**
* Get the camera fields the simple editor shows: the entity, how it is
* streamed, and how it is presented.
* @returns The camera fields.
*/
export const getCameraSimpleFields = (): HAFormSchema[] => [
{ name: 'camera_entity', selector: { entity: { domain: 'camera' } } },
{
name: 'live_provider',
selector: createSelectSelector([
{ value: 'auto', label: localize('config.cameras.live_providers.auto') },
{ value: 'ha', label: localize('config.cameras.live_providers.ha') },
{ value: 'image', label: localize('config.cameras.live_providers.image') },
{ value: 'jsmpeg', label: localize('config.cameras.live_providers.jsmpeg') },
{ value: 'go2rtc', label: localize('config.cameras.live_providers.go2rtc') },
{
value: 'go2rtc-experimental',
label: localize('config.cameras.live_providers.go2rtc-experimental'),
},
{
value: 'webrtc-card',
label: localize('config.cameras.live_providers.webrtc-card'),
},
]),
},
{ name: 'title', selector: { text: {} } },
{ name: 'icon', selector: { icon: {} } },
];
interface CameraSchemaOptions {
otherCameras: HASelectSelectorOption[];
folders: HASelectSelectorOption[];
@@ -89,27 +118,7 @@ export const getCameraSchema = (options: CameraSchemaOptions): HAFormSchema[] =>
}));
return [
{ name: 'camera_entity', selector: { entity: { domain: 'camera' } } },
{
name: 'live_provider',
selector: createSelectSelector([
{ value: 'auto', label: localize('config.cameras.live_providers.auto') },
{ value: 'ha', label: localize('config.cameras.live_providers.ha') },
{ value: 'image', label: localize('config.cameras.live_providers.image') },
{ value: 'jsmpeg', label: localize('config.cameras.live_providers.jsmpeg') },
{ value: 'go2rtc', label: localize('config.cameras.live_providers.go2rtc') },
{
value: 'go2rtc-experimental',
label: localize('config.cameras.live_providers.go2rtc-experimental'),
},
{
value: 'webrtc-card',
label: localize('config.cameras.live_providers.webrtc-card'),
},
]),
},
{ name: 'title', selector: { text: {} } },
{ name: 'icon', selector: { icon: {} } },
...getCameraSimpleFields(),
{ name: 'id', selector: { text: {} } },
{ name: 'always_error_if_entity_unavailable', selector: { boolean: {} } },
@@ -210,7 +219,7 @@ export const getCameraSchema = (options: CameraSchemaOptions): HAFormSchema[] =>
type: 'expandable',
title: localize('config.cameras.engines.editor_label'),
icon: 'mdi:engine',
docPath: 'cameras.engine',
docPath: ['cameras', 'engine'],
schema: [
{
name: 'engine',
@@ -295,7 +304,7 @@ export const getCameraSchema = (options: CameraSchemaOptions): HAFormSchema[] =>
type: 'expandable',
title: localize('config.cameras.live_provider_options.editor_label'),
icon: 'mdi:cctv',
docPath: 'cameras.live_provider',
docPath: ['cameras', 'live_provider'],
schema: [
{
name: 'go2rtc',
+21 -14
View File
@@ -1,7 +1,27 @@
import type { HASelectSelectorOption } from '../../../ha/types';
import { localize } from '../../../localize/localize';
import type { EditorForm } from '../types';
import { createSelectSelector } from './common/selectors';
/**
* Get the options for how the card's aspect ratio is decided.
* @returns The aspect ratio mode options.
*/
export const getAspectRatioModeOptions = (): HASelectSelectorOption[] => [
{
value: 'dynamic',
label: localize('config.dimensions.aspect_ratio_modes.dynamic'),
},
{
value: 'static',
label: localize('config.dimensions.aspect_ratio_modes.static'),
},
{
value: 'unconstrained',
label: localize('config.dimensions.aspect_ratio_modes.unconstrained'),
},
];
/**
* Get the form for the dimensions section.
* @returns The section forms.
@@ -12,20 +32,7 @@ export const getDimensionsSectionForms = (): EditorForm[] => [
schema: [
{
name: 'aspect_ratio_mode',
selector: createSelectSelector([
{
value: 'dynamic',
label: localize('config.dimensions.aspect_ratio_modes.dynamic'),
},
{
value: 'static',
label: localize('config.dimensions.aspect_ratio_modes.static'),
},
{
value: 'unconstrained',
label: localize('config.dimensions.aspect_ratio_modes.unconstrained'),
},
]),
selector: createSelectSelector(getAspectRatioModeOptions()),
},
{
name: 'aspect_ratio',
@@ -0,0 +1,30 @@
import { getEditorMode } from '../mode';
import type { ConfigPath, EditorForm, FieldBinding } from '../types';
const EDITOR_MODE_PATH: ConfigPath = ['editor', 'mode'];
// The switch between the two editors. It shows whether the full editor is in
// use, while the configuration names the editor itself, so the two
// representations are converted here. The mode is always written out, including
// when it matches what would have been chosen anyway, so that the choice sticks.
const getEditorModeBinding = (): FieldBinding => ({
formPath: ['mode'],
configPaths: [EDITOR_MODE_PATH],
read: (config) => getEditorMode(config) === 'full',
write: (value) => [
{ path: EDITOR_MODE_PATH, type: 'set', value: value === true ? 'full' : 'simple' },
],
});
/**
* Get the form choosing which editor to show. It is shown above both editors,
* so that the way back is in the same place whichever is in use.
* @returns The editor mode forms.
*/
export const getEditorModeForms = (): EditorForm[] => [
{
basePath: ['editor'],
schema: [{ name: 'mode', selector: { boolean: {} } }],
bindings: [getEditorModeBinding()],
},
];
+114
View File
@@ -0,0 +1,114 @@
import { CONF_CAMERAS, CONF_FOLDERS } from '../../../config/const';
import type { EditorForm } from '../types';
import {
getCameraSchema,
getCameraTriggersSchema,
getTriggerEventSchema,
} from './cameras';
import { getDimensionsSectionForms } from './dimensions';
import { getFolderSchema } from './folders';
import { getImageSectionForms } from './image';
import { getLiveSectionForms } from './live';
import { getMediaGallerySectionForms } from './media-gallery';
import { getMediaViewerSectionForms } from './media-viewer';
import { getMenuSectionForms } from './menu';
import { getPerformanceSectionForms } from './performance';
import { getProfilesSectionForms } from './profiles';
import { getRemoteControlSectionForms } from './remote-control';
import { getStatusBarSectionForms } from './status-bar';
import { getTimelineSectionForms } from './timeline';
import { getViewKeyboardShortcutsSectionForms, getViewSectionForms } from './view';
// The forms of each section of the full editor, by section name. Kept as a
// lookup rather than a switch so that the full editor's fields can also be
// asked for as a whole, without a second list of the sections to keep in step.
const SECTION_FORM_BUILDERS: Record<string, () => EditorForm[]> = {
dimensions: getDimensionsSectionForms,
image: getImageSectionForms,
live: getLiveSectionForms,
media_gallery: getMediaGallerySectionForms,
media_viewer: getMediaViewerSectionForms,
menu: getMenuSectionForms,
performance: getPerformanceSectionForms,
profiles: getProfilesSectionForms,
remote_control: getRemoteControlSectionForms,
status_bar: getStatusBarSectionForms,
timeline: getTimelineSectionForms,
view: getViewSectionForms,
'view.keyboard_shortcuts': getViewKeyboardShortcutsSectionForms,
};
/**
* Get the forms of one section of the full editor.
* @param name The section name.
* @returns The section forms, or none for a section that has no forms of its
* own.
*/
export const getFullSectionForms = (name: string): EditorForm[] =>
SECTION_FORM_BUILDERS[name]?.() ?? [];
/**
* Get the form for one camera in the full editor, without its triggers, which
* are rendered by a hand-built panel so it can host the events list.
* @param index The camera's position in the configuration.
* @param options The camera and folder lists the dropdowns choose from.
* @returns The camera's forms.
*/
export const getFullCameraForms = (
index: number,
options: Parameters<typeof getCameraSchema>[0],
): EditorForm[] => [
{ basePath: [CONF_CAMERAS, index], schema: getCameraSchema(options) },
];
/**
* Get the form for a camera's triggers.
* @param index The camera's position in the configuration.
* @returns The triggers forms.
*/
export const getFullCameraTriggersForms = (index: number): EditorForm[] => [
{
basePath: [CONF_CAMERAS, index, 'triggers'],
schema: getCameraTriggersSchema(),
},
];
/**
* Get the form for one of a camera's trigger events.
* @param cameraIndex The camera's position in the configuration.
* @param eventIndex The event's position within the camera's events.
* @returns The event's forms.
*/
export const getFullCameraEventForms = (
cameraIndex: number,
eventIndex: number,
): EditorForm[] => [
{
basePath: [CONF_CAMERAS, cameraIndex, 'triggers', 'events', eventIndex],
schema: getTriggerEventSchema(),
},
];
/**
* Get the form for one folder.
* @param index The folder's position in the configuration.
* @returns The folder's forms.
*/
export const getFullFolderForms = (index: number): EditorForm[] => [
{ basePath: [CONF_FOLDERS, index], schema: getFolderSchema() },
];
/**
* Get every form the full editor shows, wherever it shows it: its sections, and
* the pages it opens for an item of a list. The items are those at index 0,
* since every item of a list has the same fields, and the dropdowns are given
* nothing to choose from since nothing is being chosen.
* @returns The full editor's forms.
*/
export const getFullEditorForms = (): EditorForm[] => [
...Object.values(SECTION_FORM_BUILDERS).flatMap((build) => build()),
...getFullCameraForms(0, { otherCameras: [], folders: [] }),
...getFullCameraTriggersForms(0),
...getFullCameraEventForms(0, 0),
...getFullFolderForms(0),
];
+42 -16
View File
@@ -1,11 +1,15 @@
import { BUTTON_SIZE_MIN, MENU_PRIORITY_MAX } from '../../../config/schema/common/const';
import type { HAFormExpandableSchema, HAFormSchema } from '../../../ha/types';
import type {
HAFormExpandableSchema,
HAFormSchema,
HASelectSelectorOption,
} from '../../../ha/types';
import { localize } from '../../../localize/localize';
import type { EditorForm } from '../types';
import { createNumberSelector, createSelectSelector } from './common/selectors';
// The menu buttons in their display order.
const MENU_BUTTONS = [
export const MENU_BUTTONS = [
// Iris menu button is first.
'iris',
@@ -39,6 +43,40 @@ const MENU_BUTTONS = [
'timeline',
];
/**
* Get the options for choosing a menu button.
* @returns The menu buttons, in display order.
*/
export const getMenuButtonOptions = (): HASelectSelectorOption[] =>
MENU_BUTTONS.map((button) => ({
value: button,
label: localize(`config.menu.buttons.${button}`),
}));
/**
* Get the options for the menu style.
* @returns The menu style options.
*/
export const getMenuStyleOptions = (): HASelectSelectorOption[] => [
{ value: 'none', label: localize('config.menu.styles.none') },
{ value: 'hidden', label: localize('config.menu.styles.hidden') },
{ value: 'overlay', label: localize('config.menu.styles.overlay') },
{ value: 'hover', label: localize('config.menu.styles.hover') },
{ value: 'hover-card', label: localize('config.menu.styles.hover-card') },
{ value: 'outside', label: localize('config.menu.styles.outside') },
];
/**
* Get the options for the menu position.
* @returns The menu position options.
*/
export const getMenuPositionOptions = (): HASelectSelectorOption[] => [
{ value: 'left', label: localize('config.menu.positions.left') },
{ value: 'right', label: localize('config.menu.positions.right') },
{ value: 'top', label: localize('config.menu.positions.top') },
{ value: 'bottom', label: localize('config.menu.positions.bottom') },
];
// The microphone button alone carries a `type` (momentary vs toggle).
const getMicrophoneTypeSchema = (): HAFormSchema => ({
name: 'type',
@@ -116,23 +154,11 @@ export const getMenuSectionForms = (): EditorForm[] => [
schema: [
{
name: 'style',
selector: createSelectSelector([
{ value: 'none', label: localize('config.menu.styles.none') },
{ value: 'hidden', label: localize('config.menu.styles.hidden') },
{ value: 'overlay', label: localize('config.menu.styles.overlay') },
{ value: 'hover', label: localize('config.menu.styles.hover') },
{ value: 'hover-card', label: localize('config.menu.styles.hover-card') },
{ value: 'outside', label: localize('config.menu.styles.outside') },
]),
selector: createSelectSelector(getMenuStyleOptions()),
},
{
name: 'position',
selector: createSelectSelector([
{ value: 'left', label: localize('config.menu.positions.left') },
{ value: 'right', label: localize('config.menu.positions.right') },
{ value: 'top', label: localize('config.menu.positions.top') },
{ value: 'bottom', label: localize('config.menu.positions.bottom') },
]),
selector: createSelectSelector(getMenuPositionOptions()),
},
{
name: 'alignment',
+23 -18
View File
@@ -1,7 +1,29 @@
import type { HAFormSchema } from '../../../ha/types';
import { localize } from '../../../localize/localize';
import type { EditorForm } from '../types';
import { createSelectSelector } from './common/selectors';
/**
* Get the field choosing which pre-configured sets of defaults apply.
* @returns The profiles field.
*/
export const getProfilesField = (): HAFormSchema => ({
name: 'profiles',
label: localize('config.profiles.editor_label'),
selector: createSelectSelector(
[
{ value: 'casting', label: localize('config.profiles.casting') },
{ value: 'doorbell', label: localize('config.profiles.doorbell') },
{
value: 'low-performance',
label: localize('config.profiles.low-performance'),
},
{ value: 'scrubbing', label: localize('config.profiles.scrubbing') },
],
{ multiple: true },
),
});
/**
* Get the form for the profiles section.
* @returns The section forms.
@@ -9,23 +31,6 @@ import { createSelectSelector } from './common/selectors';
export const getProfilesSectionForms = (): EditorForm[] => [
{
basePath: [],
schema: [
{
name: 'profiles',
label: localize('config.profiles.editor_label'),
selector: createSelectSelector(
[
{ value: 'casting', label: localize('config.profiles.casting') },
{ value: 'doorbell', label: localize('config.profiles.doorbell') },
{
value: 'low-performance',
label: localize('config.profiles.low-performance'),
},
{ value: 'scrubbing', label: localize('config.profiles.scrubbing') },
],
{ multiple: true },
),
},
],
schema: [getProfilesField()],
},
];
+50 -92
View File
@@ -1,34 +1,37 @@
import { CONF_CAMERAS, CONF_FOLDERS } from '../../../config/const';
import type { HASelectSelectorOption } from '../../../ha/types';
import type { EditorForm } from '../types';
import { getEditorModeForms } from './editor-mode';
import {
getCameraSchema,
getCameraTriggersSchema,
getTriggerEventSchema,
} from './cameras';
import { getDimensionsSectionForms } from './dimensions';
import { getFolderSchema } from './folders';
import { getImageSectionForms } from './image';
import { getLiveSectionForms } from './live';
import { getMediaGallerySectionForms } from './media-gallery';
import { getMediaViewerSectionForms } from './media-viewer';
import { getMenuSectionForms } from './menu';
import { getPerformanceSectionForms } from './performance';
import { getProfilesSectionForms } from './profiles';
import { getRemoteControlSectionForms } from './remote-control';
import { getStatusBarSectionForms } from './status-bar';
import { getTimelineSectionForms } from './timeline';
import { getViewKeyboardShortcutsSectionForms, getViewSectionForms } from './view';
getFullCameraEventForms,
getFullCameraForms,
getFullCameraTriggersForms,
getFullFolderForms,
getFullSectionForms,
} from './full';
import {
getSimpleCameraForms,
getSimpleMenuForms,
getSimpleTopLevelForms,
} from './simple';
// The forms an editor component asks for. A request carries its indices as
// numbers rather than being named by a path string, so no caller has to build
// a string key and no builder has to pull one apart again.
export type FormRequest =
| { kind: 'section'; name: string }
| { kind: 'folder'; index: number }
| { kind: 'camera'; index: number }
| { kind: 'camera-triggers'; cameraIndex: number }
| { kind: 'camera-event'; cameraIndex: number; eventIndex: number };
// The editor mode (simple or full)
| { kind: 'editor-mode' }
// Simple mode forms.
| { kind: 'simple-camera'; index: number }
| { kind: 'simple-menu' }
| { kind: 'simple-top-level' }
// Full mode forms.
| { kind: 'full-section'; name: string }
| { kind: 'full-folder'; index: number }
| { kind: 'full-camera'; index: number }
| { kind: 'full-camera-triggers'; cameraIndex: number }
| { kind: 'full-camera-event'; cameraIndex: number; eventIndex: number };
// The lists a form's dropdowns choose from, gathered from the rest of the
// configuration: the cameras and the folders. Entity fields are not among them:
@@ -40,38 +43,6 @@ export interface FormRequestOptions {
folders: HASelectSelectorOption[];
}
const getSectionFormsByName = (name: string): EditorForm[] => {
switch (name) {
case 'dimensions':
return getDimensionsSectionForms();
case 'image':
return getImageSectionForms();
case 'live':
return getLiveSectionForms();
case 'media_gallery':
return getMediaGallerySectionForms();
case 'media_viewer':
return getMediaViewerSectionForms();
case 'menu':
return getMenuSectionForms();
case 'performance':
return getPerformanceSectionForms();
case 'profiles':
return getProfilesSectionForms();
case 'remote_control':
return getRemoteControlSectionForms();
case 'status_bar':
return getStatusBarSectionForms();
case 'timeline':
return getTimelineSectionForms();
case 'view':
return getViewSectionForms();
case 'view.keyboard_shortcuts':
return getViewKeyboardShortcutsSectionForms();
}
return [];
};
/**
* Get the forms for a request.
* @param request What forms are wanted.
@@ -83,42 +54,29 @@ export const getForms = (
options: FormRequestOptions,
): EditorForm[] => {
switch (request.kind) {
case 'section':
return getSectionFormsByName(request.name);
case 'folder':
return [{ basePath: [CONF_FOLDERS, request.index], schema: getFolderSchema() }];
case 'camera':
return [
{
basePath: [CONF_CAMERAS, request.index],
schema: getCameraSchema({
// A camera cannot depend on itself.
otherCameras: options.cameras.filter(
(_camera, index) => index !== request.index,
),
folders: options.folders,
}),
},
];
case 'camera-triggers':
return [
{
basePath: [CONF_CAMERAS, request.cameraIndex, 'triggers'],
schema: getCameraTriggersSchema(),
},
];
case 'camera-event':
return [
{
basePath: [
CONF_CAMERAS,
request.cameraIndex,
'triggers',
'events',
request.eventIndex,
],
schema: getTriggerEventSchema(),
},
];
case 'editor-mode':
return getEditorModeForms();
case 'full-section':
return getFullSectionForms(request.name);
case 'simple-camera':
return getSimpleCameraForms(request.index);
case 'simple-menu':
return getSimpleMenuForms();
case 'simple-top-level':
return getSimpleTopLevelForms();
case 'full-folder':
return getFullFolderForms(request.index);
case 'full-camera':
return getFullCameraForms(request.index, {
// A camera cannot depend on itself.
otherCameras: options.cameras.filter(
(_camera, index) => index !== request.index,
),
folders: options.folders,
});
case 'full-camera-triggers':
return getFullCameraTriggersForms(request.cameraIndex);
case 'full-camera-event':
return getFullCameraEventForms(request.cameraIndex, request.eventIndex);
}
};
+153
View File
@@ -0,0 +1,153 @@
import { CONF_CAMERAS } from '../../../config/const';
import { getConfigValue } from '../../../config/management';
import type { RawAdvancedCameraCardConfig } from '../../../config/types';
import { localize } from '../../../localize/localize';
import type { ConfigChange, ConfigPath, EditorForm, FieldBinding } from '../types';
import { getCameraSimpleFields } from './cameras';
import { createSelectSelector } from './common/selectors';
import { getAspectRatioModeOptions } from './dimensions';
import {
getMenuButtonOptions,
getMenuPositionOptions,
getMenuStyleOptions,
MENU_BUTTONS,
} from './menu';
import { getProfilesField } from './profiles';
import { getViewModeOptions } from './view';
const getMenuButtonEnabledPath = (button: string): ConfigPath => [
'menu',
'buttons',
button,
'enabled',
];
// Whether a button is shown with the configuration as it stands: what the
// configuration says, or what the (profile-adjusted) defaults say for a button
// the configuration does not mention.
const isMenuButtonEnabled = (
config: RawAdvancedCameraCardConfig,
defaults: RawAdvancedCameraCardConfig,
button: string,
): boolean => {
const path = getMenuButtonEnabledPath(button);
const configured = getConfigValue(config, path);
return typeof configured === 'boolean'
? configured
: getConfigValue(defaults, path) === true;
};
// The single control for which buttons the menu shows, standing for one
// `enabled` key per button. Roughly half the buttons are shown by default, so a
// button the user turns off has to be written out as `false` rather than having
// its key removed.
const getMenuButtonsBinding = (): FieldBinding => ({
formPath: ['menu_buttons'],
configPaths: MENU_BUTTONS.map(getMenuButtonEnabledPath),
read: (config, defaults) =>
MENU_BUTTONS.filter((button) => isMenuButtonEnabled(config, defaults, button)),
write: (value, config, defaults) => {
const selected = Array.isArray(value) ? value : [];
const changes: ConfigChange[] = [];
for (const button of MENU_BUTTONS) {
const wanted = selected.includes(button);
if (wanted === isMenuButtonEnabled(config, defaults, button)) {
continue;
}
const path = getMenuButtonEnabledPath(button);
changes.push(
wanted === (getConfigValue(defaults, path) === true)
? { path, type: 'delete' }
: { path, type: 'set', value: wanted },
);
}
return changes;
},
});
// The simple editor's fields are gathered from across the configuration, so
// each is named for what it means where it is shown and bound to where the
// setting actually lives.
export const getSimpleMenuForms = (): EditorForm[] => [
{
basePath: [],
schema: [
{
name: 'menu_style',
selector: createSelectSelector(getMenuStyleOptions()),
},
{
name: 'menu_position',
selector: createSelectSelector(getMenuPositionOptions()),
},
{
name: 'menu_buttons',
label: localize('config.menu.buttons.editor_label'),
selector: createSelectSelector(getMenuButtonOptions(), { multiple: true }),
},
],
bindings: [
{ formPath: ['menu_style'], configPath: ['menu', 'style'] },
{ formPath: ['menu_position'], configPath: ['menu', 'position'] },
getMenuButtonsBinding(),
],
},
];
/**
* Get the settings the simple editor shows above everything else. They are
* single controls with nothing behind them, so they are shown as they are
* rather than in a section that has to be opened.
* @returns The top level forms.
*/
export const getSimpleTopLevelForms = (): EditorForm[] => [
{
basePath: [],
schema: [
{
name: 'default_view',
selector: createSelectSelector(getViewModeOptions()),
},
// The ratio only means anything alongside the mode that uses it, so the
// two are shown together.
{
type: 'grid',
schema: [
{
name: 'aspect_ratio_mode',
selector: createSelectSelector(getAspectRatioModeOptions()),
},
{
name: 'aspect_ratio',
selector: { text: {} },
},
],
},
getProfilesField(),
],
bindings: [
{ formPath: ['default_view'], configPath: ['view', 'default'] },
{
formPath: ['aspect_ratio_mode'],
configPath: ['dimensions', 'aspect_ratio_mode'],
},
{ formPath: ['aspect_ratio'], configPath: ['dimensions', 'aspect_ratio'] },
],
},
];
/**
* Get the form for one camera in the simple editor: what the camera is and what
* it is called, without the settings a working camera does not need.
* @param index The camera's position in the configuration.
* @returns The camera's forms.
*/
export const getSimpleCameraForms = (index: number): EditorForm[] => [
{
basePath: [CONF_CAMERAS, index],
schema: getCameraSimpleFields(),
},
];
+5 -1
View File
@@ -4,7 +4,11 @@ import type { EditorForm } from '../types';
import { getInteractionModeOptions } from './common/interaction-mode';
import { createNumberSelector, createSelectSelector } from './common/selectors';
const getViewModeOptions = (): HASelectSelectorOption[] => [
/**
* Get the options for choosing a view.
* @returns The view options.
*/
export const getViewModeOptions = (): HASelectSelectorOption[] => [
{ value: 'auto', label: localize('config.view.views.auto') },
{ value: 'clip', label: localize('config.view.views.clip') },
{ value: 'clips', label: localize('config.view.views.clips') },
+19
View File
@@ -1,3 +1,5 @@
import { isEqual } from 'lodash-es';
import type { RawAdvancedCameraCardConfig } from '../../config/types';
import type { HAFormSchema } from '../../ha/types';
@@ -38,6 +40,11 @@ interface PathFieldBinding {
interface ComputedFieldBinding {
formPath: string[];
// Every configuration path the field stands for. Stated rather than worked
// out from the functions below, which cannot be inspected, so that what the
// form as a whole addresses is still answerable.
configPaths: ConfigPath[];
// The value to show for the field, given the whole configuration and the
// configuration defaults.
read: (
@@ -61,6 +68,18 @@ export const isComputedFieldBinding = (
binding: FieldBinding,
): binding is ComputedFieldBinding => 'read' in binding;
/**
* Get the binding for a field of a form, if it has one.
* @param form The form.
* @param formPath The field's path within the form's own data.
* @returns The binding, or undefined for a field stored where it sits.
*/
export const findBinding = (
form: EditorForm,
formPath: string[],
): FieldBinding | undefined =>
form.bindings?.find((binding) => isEqual(binding.formPath, formPath));
// One `ha-form` for part of a section. A section splits into more than one of
// these when its fields live at different configuration paths, since each form
// binds to a single base path. For example, the timeline section uses one form
+40 -24
View File
@@ -17,6 +17,7 @@ import {
} from '../../components-lib/editor/titles';
import type { ConfigPath } from '../../components-lib/editor/types';
import { CONF_CAMERAS } from '../../config/const';
import type { EditorMode } from '../../config/schema/editor';
import type { HomeAssistant } from '../../ha/types';
import { localize } from '../../localize/localize';
import editorExpanderBodyStyle from '../../scss/editor-expander-body.scss';
@@ -45,6 +46,11 @@ export class AdvancedCameraCardEditorCameras extends LitElement {
@property({ attribute: false })
public input?: FormsInput;
// Which editor this list is part of. The simple editor shows fewer of a
// camera's settings, and none of its triggers.
@property()
public mode?: EditorMode;
private _pagesController = new ListPagesController(this);
private _formsController = new ListFormsController(this, (path) =>
renderDocumentation(path),
@@ -103,8 +109,7 @@ export class AdvancedCameraCardEditorCameras extends LitElement {
private _renderCamera(index: number, hidden: boolean): TemplateResult {
const camera = this._formsController.getList(CAMERAS_PATH)[index];
const eventsPath: ConfigPath = [CONF_CAMERAS, index, 'triggers', 'events'];
const events = this._formsController.getList(eventsPath);
const fullMode = this.mode !== 'simple';
return html`
<advanced-camera-card-editor-page
@@ -114,32 +119,43 @@ export class AdvancedCameraCardEditorCameras extends LitElement {
>
${renderForms(
this.hass,
this._formsController.getFormContexts({ kind: 'camera', index }),
this._formsController.getFormContexts(
fullMode ? { kind: 'full-camera', index } : { kind: 'simple-camera', index },
),
)}
<ha-expansion-panel
outlined
.header=${localize('config.cameras.triggers.editor_label')}
>
<advanced-camera-card-icon
slot="leading-icon"
.icon=${{ icon: 'mdi:magnify-scan' }}
></advanced-camera-card-icon>
${this._renderContained(html`
${renderDocumentation([CONF_CAMERAS, 'triggers'])}
${renderForms(
this.hass,
this._formsController.getFormContexts({
kind: 'camera-triggers',
cameraIndex: index,
}),
)}
${this._renderEvents(eventsPath, events)}
`)}
</ha-expansion-panel>
${fullMode ? this._renderTriggers(index) : nothing}
</advanced-camera-card-editor-page>
`;
}
private _renderTriggers(index: number): TemplateResult {
const eventsPath: ConfigPath = [CONF_CAMERAS, index, 'triggers', 'events'];
const events = this._formsController.getList(eventsPath);
return html`
<ha-expansion-panel
outlined
.header=${localize('config.cameras.triggers.editor_label')}
>
<advanced-camera-card-icon
slot="leading-icon"
.icon=${{ icon: 'mdi:magnify-scan' }}
></advanced-camera-card-icon>
${this._renderContained(html`
${renderDocumentation([CONF_CAMERAS, 'triggers'])}
${renderForms(
this.hass,
this._formsController.getFormContexts({
kind: 'full-camera-triggers',
cameraIndex: index,
}),
)}
${this._renderEvents(eventsPath, events)}
`)}
</ha-expansion-panel>
`;
}
// The Home Assistant events the triggers watch for, a group of their own
// within the triggers panel: they are one kind of trigger among the others,
// not a sibling of the whole trigger set.
@@ -216,7 +232,7 @@ export class AdvancedCameraCardEditorCameras extends LitElement {
${renderForms(
this.hass,
this._formsController.getFormContexts({
kind: 'camera-event',
kind: 'full-camera-event',
cameraIndex,
eventIndex,
}),
+2 -3
View File
@@ -8,6 +8,7 @@ import {
import { customElement, property } from 'lit/decorators.js';
import { getDocURL } from '../../components-lib/editor/doc-links';
import type { ConfigPath } from '../../components-lib/editor/types';
import { localize } from '../../localize/localize';
import editorDocLinkStyle from '../../scss/editor-doc-link.scss';
@@ -48,9 +49,7 @@ export class AdvancedCameraCardEditorDocLink extends LitElement {
* @param path The configuration path.
* @returns A rendered template, or null when the path has no documentation.
*/
export const renderDocumentation = (
path: (string | number)[],
): TemplateResult | null => {
export const renderDocumentation = (path: ConfigPath): TemplateResult | null => {
const url = getDocURL(path);
return url
? html`<advanced-camera-card-editor-doc-link
+1 -1
View File
@@ -64,7 +64,7 @@ export class AdvancedCameraCardEditorFolders extends LitElement {
>
${renderForms(
this.hass,
this._controller.getFormContexts({ kind: 'folder', index }),
this._controller.getFormContexts({ kind: 'full-folder', index }),
)}
<ha-alert alert-type="info">
${localize('config.folders.ha.path_info')}
+3 -2
View File
@@ -14,6 +14,7 @@ import type {
} from '../../components-lib/editor/forms-controller';
import type { FormRequest } from '../../components-lib/editor/schema/registry';
import { SectionController } from '../../components-lib/editor/section-controller';
import type { ConfigPath } from '../../components-lib/editor/types';
import type { HomeAssistant } from '../../ha/types';
import editorSectionStyle from '../../scss/editor-section.scss';
import { renderDocumentation } from './doc-link';
@@ -47,7 +48,7 @@ export class AdvancedCameraCardEditorSection extends LitElement {
// The path whose documentation the section links to; the section's own forms
// supply the links for everything within them.
@property({ attribute: false })
public documentationPath?: (string | number)[];
public docPath?: ConfigPath;
// Content shown after the section's schema forms, of which there may be
// none. Called only once the section has been opened, and in the render that
@@ -96,7 +97,7 @@ export class AdvancedCameraCardEditorSection extends LitElement {
@expanded-will-change=${this._stopPropagation}
@expanded-changed=${this._stopPropagation}
>
${this.documentationPath ? renderDocumentation(this.documentationPath) : nothing}
${this.docPath ? renderDocumentation(this.docPath) : nothing}
${renderForms(this.hass, this._controller.getContexts())}
${this.renderCustomContent?.() ?? nothing}
</div>
+14
View File
@@ -0,0 +1,14 @@
import { z } from 'zod';
const EDITOR_MODES = ['simple', 'full'] as const;
const editorModeSchema = z.enum(EDITOR_MODES);
export type EditorMode = z.infer<typeof editorModeSchema>;
// Options for the visual editor. The card itself renders identically whatever
// these are set to.
export const editorConfigSchema = z.object({
// Which editor experience to show. When unset, the editor chooses for itself
// based on whether the configuration only uses settings the simple editor can
// express.
mode: editorModeSchema.optional(),
});
+2
View File
@@ -6,6 +6,7 @@ import { cameraConfigDefault, cameraConfigSchema, camerasConfigSchema } from './
import { cardIDRegex } from './common/const';
import { debugConfigDefault, debugConfigSchema, type DebugConfig } from './debug';
import { dimensionsConfigSchema } from './dimensions';
import { editorConfigSchema } from './editor';
import { pictureElementsSchema } from './elements/types';
import { foldersConfigSchema } from './folders';
import { imageConfigDefault, imageConfigSchema } from './image';
@@ -51,6 +52,7 @@ export const advancedCameraCardConfigSchema = z.object({
timeline: timelineConfigSchema,
performance: performanceConfigSchema,
debug: debugConfigSchema,
editor: editorConfigSchema.optional(),
automations: automationsSchema.optional(),
profiles: profilesSchema,
+104 -8
View File
@@ -16,9 +16,16 @@ import './components/editor/section.js';
import './components/icon.js';
import { EditorController } from './components-lib/editor/controller.js';
import type { FormsInput } from './components-lib/editor/forms-controller.js';
import {
FormsController,
type FormsInput,
} from './components-lib/editor/forms-controller.js';
import type { EditorIntent } from './components-lib/editor/intents.js';
import type { FormRequest } from './components-lib/editor/schema/registry.js';
import type { ConfigChange, ConfigPath } from './components-lib/editor/types.js';
import { renderDocumentation } from './components/editor/doc-link.js';
import { renderForms } from './components/editor/form.js';
import { CONF_CAMERAS } from './config/const.js';
import type { RawAdvancedCameraCardConfig } from './config/types.js';
import type { HAFormSchema, HomeAssistant, LovelaceCardEditor } from './ha/types.js';
import { localize } from './localize/localize.js';
@@ -30,6 +37,12 @@ interface EditorSection {
name: string;
description: string;
// The forms the section shows, absent for a section that has none of its own.
request?: FormRequest;
// The path whose documentation the section links to.
docPath?: ConfigPath;
// The part of the section a schema cannot express: a list the user adds to
// and reorders, or a key assigned by pressing it. A section whose whole
// configuration is such a list has no schema forms, and shows only this
@@ -133,6 +146,33 @@ const SECTIONS: Record<string, EditorSection> = {
},
};
// The two parts of the simple editor with more behind them than a handful of
// controls: a list of cameras to add to and drill into, and a menu whose
// buttons are a longer list than everything else in the editor put together.
// The rest of its settings are shown as they are, below.
const SIMPLE_SECTIONS: EditorSection[] = [
{
icon: 'video',
name: localize('editor.cameras'),
description: localize('editor.cameras_secondary'),
docPath: [CONF_CAMERAS],
renderCustomContent: (hass, input) => html`
<advanced-camera-card-editor-cameras
mode="simple"
.hass=${hass}
.input=${input}
></advanced-camera-card-editor-cameras>
`,
},
{
icon: 'menu',
name: localize('editor.menu'),
description: localize('editor.menu_secondary'),
request: { kind: 'simple-menu' },
docPath: ['menu'],
},
];
/**
* Used to side-load lazily-loaded selectors the editor will use.
* See {@link AdvancedCameraCardEditor._renderSelectorSideload}.
@@ -146,9 +186,13 @@ const SELECTOR_SIDELOAD_SCHEMA: HAFormSchema[] = [
{ name: 'entity', selector: { entity: {} } },
{ name: 'object', selector: { object: {} } },
{ name: 'sideload', type: 'expandable', title: '', schema: [] },
{ name: 'sideload-grid', type: 'grid', schema: [] },
];
const SELECTOR_SIDELOAD_DATA = {};
const MODE_REQUEST: FormRequest = { kind: 'editor-mode' };
const SIMPLE_TOP_LEVEL_REQUEST: FormRequest = { kind: 'simple-top-level' };
@customElement('advanced-camera-card-editor')
export class AdvancedCameraCardEditor extends LitElement implements LovelaceCardEditor {
@property({ attribute: false })
@@ -156,6 +200,20 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
private _controller = new EditorController(this);
// The forms the editor renders itself rather than within a full editor
// section: the switch between the editors, which belongs to neither of them,
// and the simple editor's remaining settings, which are shown as they are.
private _modeFormsController = this._createFormsController();
private _simpleFormsController = this._createFormsController();
private _createFormsController(): FormsController {
return new FormsController(
(changes: ConfigChange[]) =>
this._controller.applyIntent({ type: 'changes', changes }),
(path) => renderDocumentation(path),
);
}
public setConfig(config: RawAdvancedCameraCardConfig): void {
this._controller.setConfig(config);
}
@@ -186,32 +244,70 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
@advanced-camera-card:editor:intent=${(ev: CustomEvent<EditorIntent>) =>
this._controller.applyIntent(ev.detail)}
>
${this._renderNotices()}
${Object.keys(SECTIONS).map((name) => this._renderSection(name, hass, input))}
${[this._renderNotices(), this._renderModeSwitch(hass, input)]}
${this._controller.getEditorMode() === 'simple'
? this._renderSimple(hass, input)
: this._renderFull(hass, input)}
${this._renderActionButtons()}
</div>
`;
}
private _renderModeSwitch(hass: HomeAssistant, input: FormsInput): TemplateResult {
this._modeFormsController.setInput(MODE_REQUEST, input);
return html`
<div class="mode">
<advanced-camera-card-icon
.icon=${{ icon: 'mdi:tune' }}
></advanced-camera-card-icon>
${renderForms(hass, this._modeFormsController.getContexts())}
</div>
`;
}
private _renderSimple(hass: HomeAssistant, input: FormsInput): TemplateResult {
this._simpleFormsController.setInput(SIMPLE_TOP_LEVEL_REQUEST, input);
return html`
${SIMPLE_SECTIONS.map((section) => this._renderSection(section, hass, input))}
<div class="settings">
${renderForms(hass, this._simpleFormsController.getContexts())}
</div>
`;
}
private _renderFull(hass: HomeAssistant, input: FormsInput): TemplateResult {
return html`
${Object.entries(SECTIONS).map(([name, section]) =>
this._renderSection(
{
...section,
request: { kind: 'full-section', name },
docPath: [name],
},
hass,
input,
),
)}
`;
}
private _renderSection(
name: string,
section: EditorSection,
hass: HomeAssistant,
input: FormsInput,
): TemplateResult {
const section = SECTIONS[name];
const request: FormRequest = { kind: 'section', name };
const renderCustomContent = section.renderCustomContent;
return html`
<advanced-camera-card-editor-section
class="section"
.hass=${hass}
.request=${request}
.request=${section.request}
.input=${input}
.icon=${`mdi:${section.icon}`}
.heading=${section.name}
.description=${section.description}
.documentationPath=${[name]}
.docPath=${section.docPath}
.renderCustomContent=${renderCustomContent
? () => renderCustomContent(hass, input)
: undefined}
+35 -3
View File
@@ -336,6 +336,15 @@ interface StockHAFormExpandableSchema extends StockHAFormBaseSchema {
schema: readonly HAFormSchema[];
}
interface StockHAFormGridSchema extends StockHAFormBaseSchema {
type: 'grid';
// The width below which the columns fall back to one per row.
column_min_width?: string;
schema: readonly HAFormSchema[];
}
// Extensions layered onto the stock shapes; not part of `ha-form` itself.
interface CardHAFormSchemaExtensions {
// Override the standard path-derived label, e.g. to point shared fields at
@@ -356,9 +365,32 @@ export interface HAFormExpandableSchema
// the top-level `frigate`/`motioneye` camera keys.
name?: string;
// Explicit documentation-link key, for a nameless group whose link cannot be
// Explicit documentation-link path, for a nameless group whose link cannot be
// derived from a configuration path.
docPath?: string;
docPath?: string[];
}
export type HAFormSchema = HAFormSelectorSchema | HAFormExpandableSchema;
// Fields laid out in columns rather than one per row. A grid is nameless like
// any other visual-only grouping, so the fields within it are stored where they
// would be without it.
interface HAFormGridSchema
extends Omit<StockHAFormGridSchema, 'name'>,
CardHAFormSchemaExtensions {
name?: string;
}
export type HAFormSchema =
| HAFormSelectorSchema
| HAFormExpandableSchema
| HAFormGridSchema;
/**
* Whether a form node is a field the user fills in, rather than a container of
* further nodes. Recognized by the selector it carries, since `ha-form` gives a
* field nothing else to tell it apart by.
* @param schema The node's schema.
* @returns `true` for a field.
*/
export const isFormFieldSchema = (
schema: HAFormSchema,
): schema is HAFormSelectorSchema => 'selector' in schema;
-3
View File
@@ -420,8 +420,6 @@
"media_viewer_secondary": "Visor de suports estàtics (clips, instantànies o enregistraments)",
"menu": "Menú",
"menu_secondary": "Opcions d'aspecte del menú",
"move_down": "Moure cap avall",
"move_up": "Moure cap amunt",
"overrides": "Les substitucions estan actives",
"overrides_secondary": "S'han detectat substitucions de configuració dinàmica",
"performance": "Rendiment",
@@ -468,7 +466,6 @@
"no_camera_id": "No s'ha pogut determinar l'identificador de la càmera per a la següent càmera, és possible que hagis d'establir el paràmetre 'id' manualment",
"no_dashboard_or_view": "Tant els paràmetres 'dashboard_path' com 'view_path' són necessaris per al mètode d'emissió 'dashboard'",
"no_live_camera": "El paràmetre 'camera_entity' s'ha de configurar i validar per a aquest proveïdor en directe",
"reconnecting": "Reconnectant",
"too_many_automations": "Hi ha massa trucades d'automatització imbricades, comproveu la vostra configuració per veure si hi ha bucles",
"troubleshooting": "Comproveu la resolució de problemes",
"upgrade_available": "Hi ha disponible una actualització automàtica de la configuració de la targeta; visiteu l'editor de targetes visuals",
+7 -7
View File
@@ -429,6 +429,9 @@
},
"height": "Card height in CSS units (e.g. '500px')"
},
"editor": {
"mode": "Full editor"
},
"folders": {
"ha": {
"editor_label": "Home Assistant Media Folder Options",
@@ -534,6 +537,7 @@
"clips": "Clips",
"display_mode": "Display mode",
"download": "Download",
"editor_label": "Menu buttons",
"enabled": "Button enabled",
"expand": "Expand",
"folders": "Folders",
@@ -768,14 +772,13 @@
"media_viewer_secondary": "Viewer for static media",
"menu": "Menu",
"menu_secondary": "Menu look & feel options",
"move_down": "Move down",
"move_up": "Move up",
"performance": "Performance",
"performance_secondary": "Card performance options",
"profiles": "Profiles",
"profiles_secondary": "Choose pre-configured sets of defaults",
"remote_control": "Remote Control",
"remote_control_secondary": "Options for remote controlling the card",
"simple_coverage": "This card has settings the simple editor does not show. Turn on the full editor to see them",
"status_bar": "Status bar",
"status_bar_secondary": "Status bar look & feel options",
"timeline": "Timeline",
@@ -841,7 +844,6 @@
"no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually",
"no_camera_or_media_for_timeline": "No camera or media available for timeline",
"no_live_camera": "The camera_entity parameter must be set and valid for this live provider",
"reconnecting": "Reconnecting",
"too_many_automations": "Too many nested automation calls, please check your configuration for loops",
"troubleshooting": "Check troubleshooting",
"upgrade_available": "An automated card configuration upgrade is available, please visit the visual card editor",
@@ -850,8 +852,7 @@
},
"issues": {
"config_error": {
"heading": "Configuration error",
"text": "An error occurred evaluating the card configuration"
"heading": "Configuration error"
},
"config_upgrade": {
"heading": "Configuration upgrade available",
@@ -885,8 +886,7 @@
"text_only_legacy": "The legacy 'frigate-hass-card.js' resource must be replaced with 'advanced-camera-card.js'. It will be removed in a future release"
},
"media_query": {
"heading": "Media query failed",
"text": "An error occurred fetching media (e.g. thumbnails) for this view."
"heading": "Media query failed"
},
"media_unavailable": {
"heading": "Media unavailable",
-3
View File
@@ -563,8 +563,6 @@
"media_viewer_secondary": "Visionneuse de médias statiques (clips, instantanés ou enregistrements)",
"menu": "Menu",
"menu_secondary": "Options d'apparence et de convivialité du menu",
"move_down": "Descendre",
"move_up": "Déplacer vers le haut",
"overrides": "Remplacements activés",
"overrides_secondary": "Remplacements de configuration dynamique détectés",
"performance": "Performance",
@@ -620,7 +618,6 @@
"no_camera_id": "Impossible de déterminer l'identifiant de la caméra suivante. Il faudra peut-être définir le paramètre « ID » manuellement",
"no_dashboard_or_view": "Les paramètres 'dashboard_path' et 'view_path' sont requis pour la méthode de conversion 'dashboard'",
"no_live_camera": "Le paramètre camera_entity doit être défini et valide pour ce fournisseur en direct",
"reconnecting": "Reconnexion",
"too_many_automations": "Trop d'appels d'automatisation imbriqués, veuillez vérifier votre configuration pour les boucles",
"troubleshooting": "Vérifier le dépannage",
"upgrade_available": "Une mise à niveau automatisée de la configuration de la carte est disponible, veuillez visiter l'éditeur visuel de la carte",
-3
View File
@@ -307,8 +307,6 @@
"media_viewer_secondary": "Visualizzatore per supporti statici (clip, istantanee o registrazioni)",
"menu": "Menu",
"menu_secondary": "Opzioni di aspetto e funzionalità del menu",
"move_down": "Sposta verso il basso",
"move_up": "Sposta verso l'alto",
"overrides": "La sovrascrittura è attiva",
"overrides_secondary": "Rilevate sovrascritture della configurazione dinamica",
"timeline": "Timeline",
@@ -351,7 +349,6 @@
"no_camera_entity": "Impossibile trovare l'entità fotocamera",
"no_camera_id": "Impossibile determinare l'ID della telecamera , potrebbe essere necessario impostare manualmente il parametro 'ID'",
"no_live_camera": "Il parametro fotocamera_enty deve essere impostato e valido per questo provider live",
"reconnecting": "Riconnessione",
"troubleshooting": "Controllare la risoluzione dei problemi",
"upgrade_available": "È disponibile un aggiornamento di configurazione della scheda automatizzato, visitare l'editor di schede visive",
"webrtc_card_reported_error": "La scheda WebRTC ha riportato un errore",
-3
View File
@@ -618,8 +618,6 @@
"media_viewer_secondary": "Przeglądarka dla mediów statycznych (klipy, zrzuty, nagrania)",
"menu": "Menu",
"menu_secondary": "Opcje wyglądu i działania menu",
"move_down": "Przesuń w dół",
"move_up": "Przesuń w górę",
"overrides": "Nadpisania są aktywne",
"overrides_secondary": "Wykryto dynamiczne nadpisania konfiguracji",
"performance": "Wydajność",
@@ -682,7 +680,6 @@
"no_camera_or_media_for_timeline": "Brak dostępnej kamery lub mediów dla osi czasu",
"no_dashboard_or_view": "Zarówno 'dashboard_path' jak i 'view_path' są wymagane dla metody cast 'dashboard'",
"no_live_camera": "Parametr camera_entity musi być ustawiony i prawidłowy dla tego dostawcy",
"reconnecting": "Ponowne łączenie",
"too_many_automations": "Zbyt wiele zagnieżdżonych wywołań automatyzacji, sprawdź konfigurację pod kątem pętli",
"troubleshooting": "Sprawdź rozwiązywanie problemów",
"upgrade_available": "Dostępna jest automatyczna aktualizacja konfiguracji karty, odwiedź wizualny edytor karty",
-3
View File
@@ -312,8 +312,6 @@
"media_viewer_secondary": "Opções do visualizador de Snapshots e clipes",
"menu": "Menu",
"menu_secondary": "Opções de aparência do menu",
"move_down": "Descer",
"move_up": "Subir",
"overrides": "As substituições estão ativas",
"overrides_secondary": "Substituições de configuração dinâmica detectadas",
"performance": "Desempenho",
@@ -358,7 +356,6 @@
"no_camera_entity": "Não foi possível encontrar a entidade da câmera",
"no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente",
"no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este provedor ativo",
"reconnecting": "Reconectando",
"troubleshooting": "Verifique a solução de problemas",
"upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão",
"webrtc_card_reported_error": "O cartão WebRTC relatou um erro",
-3
View File
@@ -307,8 +307,6 @@
"media_viewer_secondary": "Opções do visualizador de Snapshots e clipes",
"menu": "Menu",
"menu_secondary": "Opções de aparência do menu",
"move_down": "Descer",
"move_up": "Subir",
"overrides": "As substituições estão ativas",
"overrides_secondary": "Substituições de configuração dinâmica detectadas",
"timeline": "Linha do tempo",
@@ -351,7 +349,6 @@
"no_camera_entity": "Não existe uma entidade câmera",
"no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente",
"no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este serviço ativo",
"reconnecting": "A voltar a ligar",
"troubleshooting": "Verifique a solução de problemas",
"upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão",
"webrtc_card_reported_error": "O cartão WebRTC relatou um erro",
+34
View File
@@ -13,6 +13,40 @@
margin-bottom: 8px;
}
// The switch between the editors, set apart from what it switches: it is a
// control over the editor rather than one of the card settings below it. Its
// icon sits beside the form so the row reads as one of the editor's rows, each
// of which carries an icon.
.mode {
display: flex;
align-items: center;
margin-bottom: 8px;
padding: 0 10px 8px;
border-bottom: 1px solid var(--advanced-camera-card-divider-color);
}
// Spaced and de-emphasized to match the icons on the sections below. The form
// insets its own label, so the gap here is the remainder of that spacing.
.mode advanced-camera-card-icon {
margin-right: 8px;
color: var(--advanced-camera-card-editor-secondary-text-color);
}
.mode ha-form {
flex: 1;
}
// The simple editor's settings that are not in a section. They are inset to
// line up with the sections above, whose outlines the panels draw themselves.
.settings ha-form {
display: block;
margin: 8px 10px;
}
// Each top-level section is an HA expansion panel. An open section draws its
// own outline, which would otherwise sit directly against the neighbouring
// sections, so every section is spaced whether it is open or not: a gap that