Skip to content

Commit

Permalink
feat: Add permanent menu items for hidden menus (#1636)
Browse files Browse the repository at this point in the history
  • Loading branch information
dermotduffy authored Oct 13, 2024
1 parent 9ca56d0 commit 0ec9afb
Show file tree
Hide file tree
Showing 15 changed files with 230 additions and 85 deletions.
123 changes: 101 additions & 22 deletions docs/configuration/elements/custom/README.md

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions docs/configuration/menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,20 @@ menu:

### Options for each button

| Option | Default | Description |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `alignment` | `matching` | Whether this button should have an alignment that is `matching` the menu alignment or `opposing` the menu. Can be used to create two separate groups of buttons on the menu. `priority` orders buttons within a given `alignment`. |
| `enabled` | `true` for `frigate`, `cameras`, `substreams`, `live`, `clips`, `snapshots`, `timeline`, `download`, `camera_ui`, `fullscreen`, `media_player`, `display_mode` and `ptz_home`. `false` for `image`, `expand`, `microphone`, `mute`, `play`, `recordings`, `screenshot`, `ptz_controls` | Whether or not to show the button. |
| `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. |
| `priority` | `50` | The button priority. Higher priority buttons are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`, when the menu alignment is `left`). Minimum `0`, maximum `100`. |
| Option | Default | Description |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `alignment` | `matching` | Whether this button should have an alignment that is `matching` the menu alignment or `opposing` the menu. Can be used to create two separate groups of buttons on the menu. `priority` orders buttons within a given `alignment`. |
| `enabled` | `true` for `frigate`, `cameras`, `substreams`, `live`, `clips`, `snapshots`, `timeline`, `download`, `camera_ui`, `fullscreen`, `media_player`, `display_mode` and `ptz_home`. `false` for `image`, `expand`, `microphone`, `mute`, `play`, `recordings`, `screenshot`, `ptz_controls` | Whether or not to show the button. |
| `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. |
| `permanent` | `false` | If `false` the menu item is hidden when the menu has the `hidden` style and the menu is closed, otherwise it is shown (and sorted to the front). |

## `style`

This card supports several menu styles.

| Key | Description | Screenshot |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `hidden` | Hide the menu by default, expandable upon clicking the Frigate button. | ![](../images/menu-mode-hidden.png 'Menu hidden :size=400') |
| `hidden` | Hide the menu by default. It may be toggled open as needed. | ![](../images/menu-mode-hidden.png 'Menu hidden :size=400') |
| `hover-card` | Overlay the menu over the card contents when the mouse is over the **card**, otherwise it is not shown. The Frigate button shows the default view. | ![](../images/menu-mode-overlay.png 'Menu hover-card :size=400') |
| `hover` | Overlay the menu over the card contents when the mouse is over the **menu**, otherwise it is not shown. The Frigate button shows the default view. | ![](../images/menu-mode-overlay.png 'Menu hover :size=400') |
| `none` | No menu is shown. | ![](../images/menu-mode-none.png 'No menu :size=400') |
Expand Down
2 changes: 0 additions & 2 deletions src/card-controller/actions/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,6 @@ export class ActionFactory {
case 'fullscreen':
return new FullscreenAction(context, frigateCardAction, options?.config);
case 'menu_toggle':
// This is a rare code path: this would only be used if someone has a
// menu toggle action configured outside of the menu itself.
return new MenuToggleAction(context, frigateCardAction, options?.config);
case 'camera_select':
return new CameraSelectAction(context, frigateCardAction, options?.config);
Expand Down
7 changes: 5 additions & 2 deletions src/components-lib/menu-button-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export class MenuButtonController {
options?: MenuButtonControllerOptions,
): MenuItem[] {
return [
this._getFrigateButton(config),
this._getDefaultButton(config),
this._getCamerasButton(config, cameraManager, options?.view),
this._getSubstreamsButton(config, cameraManager, options?.view),
this._getLiveButton(config, options?.view, options?.viewManager),
Expand Down Expand Up @@ -105,14 +105,17 @@ export class MenuButtonController {
].filter(isTruthy);
}

protected _getFrigateButton(config: FrigateCardConfig): MenuItem {
protected _getDefaultButton(config: FrigateCardConfig): MenuItem {
return {
// Use a magic icon value that the menu will use to render the custom
// Frigate icon.
icon: FRIGATE_BUTTON_MENU_ICON,
...config.menu.buttons.frigate,
type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.frigate'),
// The default button always shows regardless of whether the menu is
// hidden or not.
permanent: true,
tap_action:
config.menu?.style === 'hidden'
? (createGeneralAction('menu_toggle') as FrigateCardCustomAction)
Expand Down
77 changes: 29 additions & 48 deletions src/components-lib/menu-controller.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers';
import { LitElement } from 'lit';
import { orderBy } from 'lodash-es';
import { FRIGATE_ICON_SVG_PATH } from '../camera-manager/frigate/icon.js';
import type {
ActionType,
ActionsConfig,
MenuConfig,
MenuItem,
import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request.js';
import {
FRIGATE_MENU_PRIORITY_MAX,
type ActionType,
type ActionsConfig,
type MenuConfig,
type MenuItem,
} from '../config/types.js';
import { FRIGATE_BUTTON_MENU_ICON } from '../const.js';
import { StateParameters } from '../types.js';
Expand All @@ -15,7 +18,6 @@ import {
} from '../utils/action';
import { arrayify, isTruthy, setOrRemoveAttribute } from '../utils/basic.js';
import { refreshDynamicStateParameters } from '../utils/ha/index.js';
import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request.js';

export class MenuController {
protected _host: LitElement;
Expand Down Expand Up @@ -59,8 +61,6 @@ export class MenuController {
}

public getButtons(alignment: 'matching' | 'opposing'): MenuItem[] {
const style = this._config?.style;

const aligned = (button: MenuItem): boolean => {
return (
button.alignment === alignment || (alignment === 'matching' && !button.alignment)
Expand All @@ -71,16 +71,12 @@ export class MenuController {
return button.enabled !== false;
};

const suitableToShowIfHiddenMenu = (button: MenuItem): boolean => {
// If the hidden menu isn't expanded, only show the Frigate button.
return (
style !== 'hidden' || this._expanded || button.icon === FRIGATE_BUTTON_MENU_ICON
);
const show = (button: MenuItem): boolean => {
return !this._isHidingMenu() || this._expanded || !!button.permanent;
};

return this._buttons.filter(
(button) =>
enabled(button) && aligned(button) && suitableToShowIfHiddenMenu(button),
(button) => enabled(button) && aligned(button) && show(button),
);
}

Expand Down Expand Up @@ -126,7 +122,7 @@ export class MenuController {
let menuToggle = false;

const toggleLessActions = actions.filter(
(item) => isTruthy(item) && !this._isMenuToggleAction(item),
(item) => isTruthy(item) && !this._isUnknownActionMenuToggleAction(item),
);
if (toggleLessActions.length != actions.length) {
menuToggle = true;
Expand Down Expand Up @@ -176,42 +172,27 @@ export class MenuController {
}

protected _sortButtons(): void {
const style = this._config?.style;
const sortButtons = (a: MenuItem, b: MenuItem): number => {
// If the menu is hidden, the Frigate button must come first.
if (style === 'hidden') {
if (a.icon === FRIGATE_BUTTON_MENU_ICON) {
return -1;
} else if (b.icon === FRIGATE_BUTTON_MENU_ICON) {
return 1;
}
}

// Otherwise sort by priority.
if (
a.priority === undefined ||
(b.priority !== undefined && b.priority > a.priority)
) {
return 1;
}
if (
b.priority === undefined ||
(a.priority !== undefined && b.priority < a.priority)
) {
return -1;
}
return 0;
};

this._buttons.sort(sortButtons);
this._buttons = orderBy(
this._buttons,
(button) => {
const priority = button.priority ?? 0;
// If the menu is hidden, the buttons that toggle the menu must come
// first.
return (
priority +
(this._isHidingMenu() && button.permanent ? FRIGATE_MENU_PRIORITY_MAX : 0)
);
},
['desc'],
);
}

protected _isHidingMenu(): boolean {
return this._config?.style === 'hidden' ?? false;
return this._config?.style === 'hidden';
}

protected _isMenuToggleAction(action: ActionType): boolean {
const frigateCardAction = convertActionToCardCustomAction(action);
return !!frigateCardAction && frigateCardAction.frigate_card_action == 'menu_toggle';
protected _isUnknownActionMenuToggleAction(action: ActionType): boolean {
const parsedAction = convertActionToCardCustomAction(action);
return !!parsedAction && parsedAction.frigate_card_action == 'menu_toggle';
}
}
1 change: 1 addition & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,7 @@ const menuBaseSchema = z.object({
.optional(),
alignment: z.enum(['matching', 'opposing']).default('matching').optional(),
icon: z.string().optional(),
permanent: z.boolean().default(false).optional(),
});

const menuIconSchema = menuBaseSchema.merge(iconSchema).extend({
Expand Down
7 changes: 7 additions & 0 deletions src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1292,6 +1292,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
label: localize('config.menu.buttons.alignment'),
},
)}
${this._renderSwitch(
`${CONF_MENU_BUTTONS}.${button}.permanent`,
this._defaults.menu.buttons[button]?.permanent ?? false,
{
label: localize('config.menu.buttons.permanent'),
},
)}
${this._renderNumberInput(`${CONF_MENU_BUTTONS}.${button}.priority`, {
max: FRIGATE_MENU_PRIORITY_MAX,
default: this._defaults.menu.buttons[button]?.priority,
Expand Down
1 change: 1 addition & 0 deletions src/localize/languages/ca.json
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@
"media_player": "Envia al reproductor multimèdia",
"microphone": "Micròfon",
"mute": "Silenciar / Activar el so",
"permanent": "",
"play": "Reproduir / Pausa",
"priority": "Prioritat",
"ptz_controls": "",
Expand Down
1 change: 1 addition & 0 deletions src/localize/languages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@
"media_player": "Send to media player",
"microphone": "Microphone",
"mute": "Mute / Unmute",
"permanent": "Show when the menu is hidden",
"play": "Play / Pause",
"priority": "Priority",
"ptz_controls": "Show PTZ controls",
Expand Down
1 change: 1 addition & 0 deletions src/localize/languages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@
"media_player": "Envoyer au lecteur multimédia",
"microphone": "Microphone",
"mute": "Désactiver/Réactiver le son",
"permanent": "",
"play": "Jouer / Pause",
"priority": "Priorité",
"ptz_controls": "",
Expand Down
1 change: 1 addition & 0 deletions src/localize/languages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@
"media_player": "Invia a Media Player",
"microphone": "",
"mute": "",
"permanent": "",
"play": "",
"priority": "Priorità",
"ptz_controls": "",
Expand Down
1 change: 1 addition & 0 deletions src/localize/languages/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@
"media_player": "Enviar para o reprodutor de mídia",
"microphone": "",
"mute": "",
"permanent": "",
"play": "",
"priority": "Prioridade",
"ptz_controls": "",
Expand Down
1 change: 1 addition & 0 deletions src/localize/languages/pt-PT.json
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@
"media_player": "Enviar para o reprodutor de mídia",
"microphone": "",
"mute": "",
"permanent": "",
"play": "",
"priority": "Prioridade",
"ptz_controls": "",
Expand Down
4 changes: 3 additions & 1 deletion tests/components-lib/menu-button-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,14 @@ describe('MenuButtonController', () => {
controller = new MenuButtonController();
});

describe('should have frigate menu button', () => {
describe('should have default menu button', () => {
it('with hidden menu style', () => {
const buttons = calculateButtons(controller);

expect(buttons).toContainEqual({
icon: 'frigate',
enabled: true,
permanent: true,
priority: 50,
type: 'custom:frigate-card-menu-icon',
title: 'Frigate menu / Default view',
Expand All @@ -104,6 +105,7 @@ describe('MenuButtonController', () => {
expect(buttons).toContainEqual({
icon: 'frigate',
enabled: true,
permanent: true,
priority: 50,
type: 'custom:frigate-card-menu-icon',
title: 'Frigate menu / Default view',
Expand Down
Loading

0 comments on commit 0ec9afb

Please sign in to comment.