From f140f6a653a6bfdb945634dff3818cef3faab40b Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 23 Mar 2025 14:49:47 -0700 Subject: [PATCH] feat: Allow control of where selected item is in grid (#1979) - Closes: #1946 --- docs/configuration/grid-layout-algorithm.md | 27 ++++++-- docs/configuration/live.md | 16 +++-- docs/configuration/media-viewer.md | 16 +++-- src/components-lib/media-grid-controller.ts | 48 ++++++++++++-- src/config/schema/common/display.ts | 3 + src/const.ts | 4 ++ src/editor.ts | 32 +++++++-- src/localize/languages/ca.json | 6 ++ src/localize/languages/en.json | 16 +++-- src/localize/languages/fr.json | 6 ++ src/localize/languages/it.json | 6 ++ src/localize/languages/pt-BR.json | 6 ++ src/localize/languages/pt-PT.json | 6 ++ .../media-grid-controller.test.ts | 66 ++++++++++++++++++- 14 files changed, 226 insertions(+), 32 deletions(-) diff --git a/docs/configuration/grid-layout-algorithm.md b/docs/configuration/grid-layout-algorithm.md index f870dc0d..de965351 100644 --- a/docs/configuration/grid-layout-algorithm.md +++ b/docs/configuration/grid-layout-algorithm.md @@ -1,10 +1,29 @@ # Grid Layout Algorithm -When display mode (in `live` or `media_viewer` views) is set to `grid`, it will lay out cameras roughly in the order they are specified in the config (items may be moved to optimize grid 'density'). +When display mode (in [`live`](live.md?id=display) or +[`media_viewer`](media-viewer.md?id=display) views) is set to `grid`, this +algorithm is used to control the layout. -The following algorithm is used to calculate the number of columns. This attempts to offers a balance between configurability, reasonable display in a typical Lovelace card width and reasonable display in a typical fullscreen display. +## Layout Order + +Cameras are laid out horizontally in the order they are specified in the config, +first to last. The card may tweak item positioning in order to optimize grid +'density'. + +In addition, if the `grid_selected_position` parameter is `first` or `last`, the +selected camera is always laid out first (at the top) or last (at the bottom) of +the layout. + +## Number of columns in the grid + +The following algorithm is used to calculate the number of columns. This +attempts to offers a balance between configurability, reasonable display in a +typical Lovelace card width and reasonable display in a typical fullscreen +display. - Use `grid_columns` if specified. -- Otherwise, use the largest number of columns in the range `[2 - grid_max_columns]` that will fit at least a `600px` column width. -- Otherwise, use the largest number of columns in the range `[2 - grid_max_columns]` that will fit at least a `190px` column width. +- Otherwise, use the largest number of columns in the range `[2 - +grid_max_columns]` that will fit at least a `600px` column width. +- Otherwise, use the largest number of columns in the range `[2 - +grid_max_columns]` that will fit at least a `190px` column width. - Otherwise, there will be `1` column only. diff --git a/docs/configuration/live.md b/docs/configuration/live.md index 0e97c7c3..1d278aa7 100644 --- a/docs/configuration/live.md +++ b/docs/configuration/live.md @@ -153,12 +153,15 @@ live: # [...] ``` -| Option | Default | Description | -| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `grid_columns` | | If specified the grid will always have exactly this number of columns. | -| `grid_max_columns` | `4` | If specified, and `grid_columns` is not specified, the grid will not render more than this number of columns. The precise number will be calculated based on the [grid layout algorithm](grid-layout-algorithm.md). | -| `grid_selected_width_factor` | `2` | How much to scale up the selected media item in a grid. A value of `1` will not scale the selected item at all, the default value of `2` will scale the media item width to twice what it would otherwise be, etc. | -| `mode` | `single` | Whether to display a `single` live camera in a carousel, or all cameras in a `grid` configuration. | +| Option | Default | Description | +| ---------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `grid_columns` | | If specified the grid will always have exactly this number of columns. | +| `grid_max_columns` | `4` | If specified, and `grid_columns` is not specified, the grid will not render more than this number of columns. | +| `grid_selected_position` | `default` | Controls where the selected item should be laid out in the grid. If `default`, the cameras are laid out in the order they are specified in the configuration and selecting a camera does not change this order. If `first`, the selected camera is moved to the start of the grid, if `last` it is moved to the end of the grid. | +| `grid_selected_width_factor` | `2` | How much to scale up the selected media item in a grid. A value of `1` will not scale the selected item at all, the default value of `2` will scale the media item width to twice what it would otherwise be, etc. | +| `mode` | `single` | Whether to display a `single` live camera in a carousel, or all cameras in a `grid` configuration. | + +See the [grid layout algorithm](grid-layout-algorithm.md) for more details on how the grid lays elements out. ## `microphone` @@ -238,6 +241,7 @@ live: mute_after_microphone_mute_seconds: 60 display: mode: single + grid_selected_position: default grid_selected_width_factor: 2 grid_max_columns: 4 actions: diff --git a/docs/configuration/media-viewer.md b/docs/configuration/media-viewer.md index a43ef42e..1c701871 100644 --- a/docs/configuration/media-viewer.md +++ b/docs/configuration/media-viewer.md @@ -147,12 +147,15 @@ media_viewer: # [...] ``` -| Option | Default | Description | -| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `grid_columns` | | If specified the grid will always have exactly this number of columns. | -| `grid_max_columns` | `4` | If specified, and `grid_columns` is not specified, the grid will not render more than this number of columns. The precise number will be calculated based on the [grid layout algorithm](grid-layout-algorithm.md). | -| `grid_selected_width_factor` | `2` | How much to scale up the selected media item in a grid. A value of `1` will not scale the selected item at all, the default value of `2` will scale the media item width to twice what it would otherwise be, etc. | -| `mode` | `single` | Whether to display a `single` media item at a time, or a media item for all cameras in a `grid` configuration. | +| Option | Default | Description | +| ---------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `grid_columns` | | If specified the grid will always have exactly this number of columns. | +| `grid_max_columns` | `4` | If specified, and `grid_columns` is not specified, the grid will not render more than this number of columns. | +| `grid_selected_position` | `default` | Controls where the selected item should be laid out in the grid. If `default`, the cameras are laid out in the order they are specified in the configuration and selecting a camera does not change this order. If `first`, the selected camera is moved to the start of the grid, if `last` it is moved to the end of the grid. | +| `grid_selected_width_factor` | `2` | How much to scale up the selected media item in a grid. A value of `1` will not scale the selected item at all, the default value of `2` will scale the media item width to twice what it would otherwise be, etc. | +| `mode` | `single` | Whether to display a `single` media item at a time, or a media item for all cameras in a `grid` configuration. | + +See the [grid layout algorithm](grid-layout-algorithm.md) for more details on how the grid lays elements out. ## Fully expanded reference @@ -209,6 +212,7 @@ media_viewer: 24h: true display: mode: single + grid_selected_position: default grid_selected_width_factor: 2 grid_max_columns: 4 actions: diff --git a/src/components-lib/media-grid-controller.ts b/src/components-lib/media-grid-controller.ts index 43ff28a4..dea4ebb6 100644 --- a/src/components-lib/media-grid-controller.ts +++ b/src/components-lib/media-grid-controller.ts @@ -34,13 +34,21 @@ export interface MediaGridConstructorOptions { displayConfig?: ViewDisplayConfig; } +export interface ExtendedMasonry extends Masonry { + // Expose the items array to allow for custom ordering (used for the + // `grid_selected_position` parameter). + items: { + element: MediaGridChild; + }[]; +} + export class MediaGridController { protected _host: HTMLElement; protected _selected: GridID | null; protected _mediaLoadedInfoMap: Map = new Map(); protected _gridContents: MediaGridContents = new Map(); - protected _masonry: Masonry | null = null; + protected _masonry: ExtendedMasonry | null = null; protected _displayConfig: ViewDisplayConfig | null = null; protected _hostWidth: number; protected _idAttribute: string; @@ -128,6 +136,34 @@ export class MediaGridController { return this._selected; } + protected _sortItemsInGrid(): void { + const existingItems = this._masonry?.items; + const selectedItem = existingItems?.find( + (item) => item.element.getAttribute(this._idAttribute) === this._selected, + ); + + // If `grid_selected_position` is set to 'first' or 'last', move the + // selected item to the start or end of the list respectively. + if ( + !!this._displayConfig?.grid_selected_position && + ['first', 'last'].includes(this._displayConfig.grid_selected_position) && + existingItems && + selectedItem && + this._masonry + ) { + // Implementation note: With the latest version of the Masonry library + // (4.2.2) using the prepended() and appended() methods in quick sucession + // causes the layout to not show the newly added items. Instead, access + // the items in place and swap them around. + const otherItems = existingItems?.filter((item) => item !== selectedItem); + const newItems = + this._displayConfig.grid_selected_position === 'first' + ? [selectedItem, ...otherItems] + : [...otherItems, selectedItem]; + this._masonry.items = newItems; + } + } + public selectCell(id: GridID) { if (this._selected === id) { return; @@ -141,11 +177,12 @@ export class MediaGridController { dispatchExistingMediaLoadedInfoAsEvent(this._host, mediaLoadedInfo); } + this._sortItemsInGrid(); this._updateSelectedStylesOnElements(); - // Sizes may change when an element is selected, so re-do the layout (must - // come after the call to _updateStylesOnElements in order to ensure the - // right styles are applied first). + // Sizes and positions may change when an element is selected, so re-do the + // layout (must come after the call to _updateStylesOnElements in order to + // ensure the right styles are applied first). this._throttledLayout(); } @@ -203,6 +240,7 @@ export class MediaGridController { this._cellResizeObserver.observe(child); } + this._sortItemsInGrid(); this._updateSelectedStylesOnElements(); this._setColumnSizeStyles(); } @@ -275,7 +313,7 @@ export class MediaGridController { percentPosition: true, transitionDuration: '0.2s', gutter: MEDIA_GRID_HORIZONTAL_GUTTER_WIDTH, - }); + }) as ExtendedMasonry; this._masonry.addItems?.([...this._gridContents.values()]); this._throttledLayout(); } diff --git a/src/config/schema/common/display.ts b/src/config/schema/common/display.ts index 1b47cb3c..dbd696ec 100644 --- a/src/config/schema/common/display.ts +++ b/src/config/schema/common/display.ts @@ -3,9 +3,12 @@ import { z } from 'zod'; export const viewDisplayModeSchema = z.enum(['single', 'grid']); export type ViewDisplayMode = z.infer; +const gridSelectedPositionSchema = z.enum(['default', 'first', 'last']); + export const viewDisplaySchema = z .object({ mode: viewDisplayModeSchema.optional(), + grid_selected_position: gridSelectedPositionSchema.optional(), grid_selected_width_factor: z.number().min(0).optional(), grid_max_columns: z.number().min(0).optional(), grid_columns: z.number().min(0).optional(), diff --git a/src/const.ts b/src/const.ts index d00ce437..e69ef4f9 100644 --- a/src/const.ts +++ b/src/const.ts @@ -192,6 +192,8 @@ export const CONF_MEDIA_VIEWER_DISPLAY_GRID_COLUMNS = `${CONF_MEDIA_VIEWER}.display.grid_columns` as const; export const CONF_MEDIA_VIEWER_DISPLAY_GRID_MAX_COLUMNS = `${CONF_MEDIA_VIEWER}.display.grid_max_columns` as const; +export const CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_POSITION = + `${CONF_MEDIA_VIEWER}.display.grid_selected_position` as const; export const CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_WIDTH_FACTOR = `${CONF_MEDIA_VIEWER}.display.grid_selected_width_factor` as const; export const CONF_MEDIA_VIEWER_DRAGGABLE = `${CONF_MEDIA_VIEWER}.draggable` as const; @@ -294,6 +296,8 @@ export const CONF_LIVE_DISPLAY_GRID_COLUMNS = `${CONF_LIVE}.display.grid_columns` as const; export const CONF_LIVE_DISPLAY_GRID_MAX_COLUMNS = `${CONF_LIVE}.display.grid_max_columns` as const; +export const CONF_LIVE_DISPLAY_GRID_SELECTED_POSITION = + `${CONF_LIVE}.display.grid_selected_position` as const; export const CONF_LIVE_DISPLAY_GRID_SELECTED_WIDTH_FACTOR = `${CONF_LIVE}.display.grid_selected_width_factor` as const; export const CONF_LIVE_DRAGGABLE = `${CONF_LIVE}.draggable` as const; diff --git a/src/editor.ts b/src/editor.ts index 689ab75c..da121a74 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -126,6 +126,7 @@ import { CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS, CONF_LIVE_DISPLAY_GRID_COLUMNS, CONF_LIVE_DISPLAY_GRID_MAX_COLUMNS, + CONF_LIVE_DISPLAY_GRID_SELECTED_POSITION, CONF_LIVE_DISPLAY_GRID_SELECTED_WIDTH_FACTOR, CONF_LIVE_DISPLAY_MODE, CONF_LIVE_DRAGGABLE, @@ -167,6 +168,7 @@ import { CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS, CONF_MEDIA_VIEWER_DISPLAY_GRID_COLUMNS, CONF_MEDIA_VIEWER_DISPLAY_GRID_MAX_COLUMNS, + CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_POSITION, CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_WIDTH_FACTOR, CONF_MEDIA_VIEWER_DISPLAY_MODE, CONF_MEDIA_VIEWER_DRAGGABLE, @@ -649,6 +651,22 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard { value: 'grid', label: localize('display_modes.grid') }, ]; + protected _gridSelectPositions: EditorSelectOption[] = [ + { value: '', label: '' }, + { + value: 'default', + label: localize('config.common.display.grid_selected_positions.default'), + }, + { + value: 'first', + label: localize('config.common.display.grid_selected_positions.first'), + }, + { + value: 'last', + label: localize('config.common.display.grid_selected_positions.last'), + }, + ]; + protected _castMethods: EditorSelectOption[] = [ { value: '', label: '' }, { value: 'standard', label: localize('config.cameras.cast.methods.standard') }, @@ -1651,14 +1669,11 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard protected _renderViewDisplay( domain: string, configPathMode: string, + configPathSelectedPosition: string, configPathSelectedWidthFactor: string, configPathColumns: string, configPathMaxColumns: string, ): TemplateResult | void { - // grid_select_width_factor: z.number().min(0).optional(), - // grid_max_columns: z.number().min(0).optional(), - // grid_columns: z.number().min(0).optional(), - return this._putInSubmenu( domain, true, @@ -1668,6 +1683,13 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard ${this._renderOptionSelector(configPathMode, this._displayModes, { label: localize('config.common.display.mode'), })} + ${this._renderOptionSelector( + configPathSelectedPosition, + this._gridSelectPositions, + { + label: localize('config.common.display.grid_selected_position'), + }, + )} ${this._renderNumberInput(configPathSelectedWidthFactor, { min: 0, label: localize('config.common.display.grid_selected_width_factor'), @@ -2711,6 +2733,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard ${this._renderViewDisplay( MENU_LIVE_DISPLAY, CONF_LIVE_DISPLAY_MODE, + CONF_LIVE_DISPLAY_GRID_SELECTED_POSITION, CONF_LIVE_DISPLAY_GRID_SELECTED_WIDTH_FACTOR, CONF_LIVE_DISPLAY_GRID_COLUMNS, CONF_LIVE_DISPLAY_GRID_MAX_COLUMNS, @@ -2898,6 +2921,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard ${this._renderViewDisplay( MENU_MEDIA_VIEWER_DISPLAY, CONF_MEDIA_VIEWER_DISPLAY_MODE, + CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_POSITION, CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_WIDTH_FACTOR, CONF_MEDIA_VIEWER_DISPLAY_GRID_COLUMNS, CONF_MEDIA_VIEWER_DISPLAY_GRID_MAX_COLUMNS, diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index a1926687..3b5c2ac3 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -259,6 +259,12 @@ "editor_label": "Visualització", "grid_columns": "Nombre exacte de columnes de la graella", "grid_max_columns": "Nombre màxim de columnes de la graella", + "grid_selected_position": "", + "grid_selected_positions": { + "default": "", + "first": "", + "last": "" + }, "grid_selected_width_factor": "Augmenta l'amplada del suport multimèdia seleccionat en aquest factor", "mode": "Mode" }, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 25902fc4..4d95f594 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -237,8 +237,8 @@ "timeline": { "editor_label": "Mini Timeline", "format": { - "editor_label": "Time & date format", - "24h": "Use 24-hour clock" + "24h": "Use 24-hour clock", + "editor_label": "Time & date format" }, "mode": "Mode", "modes": { @@ -259,6 +259,12 @@ "editor_label": "Display", "grid_columns": "Exact number of grid columns", "grid_max_columns": "Maximum number of grid columns", + "grid_selected_position": "Position of selected media in grid", + "grid_selected_positions": { + "default": "Selected item in default position", + "first": "Selected item is first in grid", + "last": "Selected item is last in grid" + }, "grid_selected_width_factor": "Increase selected media width by this factor", "mode": "Mode" }, @@ -465,8 +471,8 @@ }, "remote_control": { "entities": { - "editor_label": "Remote Control Entities", - "camera": "Input Select entity to control camera" + "camera": "Input Select entity to control camera", + "editor_label": "Remote Control Entities" } }, "status_bar": { @@ -528,8 +534,8 @@ "theme": { "themes": { "dark": "Dark", - "ha": "Home Assistant", "editor_label": "Themes", + "ha": "Home Assistant", "light": "Light", "traditional": "Traditional" } diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index a3cf642e..704a8a50 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -259,6 +259,12 @@ "editor_label": "Affichage", "grid_columns": "Nombre de colonnes de la grille", "grid_max_columns": "Nombre maximum de colonnes de la grille", + "grid_selected_position": "", + "grid_selected_positions": { + "default": "", + "first": "", + "last": "" + }, "grid_selected_width_factor": "Augmenter la largeur du média sélectionnée par ce facteur", "mode": "Mode" }, diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 2a814974..b1ebe29b 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -259,6 +259,12 @@ "editor_label": "", "grid_columns": "", "grid_max_columns": "", + "grid_selected_position": "", + "grid_selected_positions": { + "default": "", + "first": "", + "last": "" + }, "grid_selected_width_factor": "", "mode": "" }, diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 3c807654..ce8ae30b 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -259,6 +259,12 @@ "editor_label": "", "grid_columns": "", "grid_max_columns": "", + "grid_selected_position": "", + "grid_selected_positions": { + "default": "", + "first": "", + "last": "" + }, "grid_selected_width_factor": "", "mode": "" }, diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index b76848b3..30373b0c 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -259,6 +259,12 @@ "editor_label": "", "grid_columns": "", "grid_max_columns": "", + "grid_selected_position": "", + "grid_selected_positions": { + "default": "", + "first": "", + "last": "" + }, "grid_selected_width_factor": "", "mode": "" }, diff --git a/tests/components-lib/media-grid-controller.test.ts b/tests/components-lib/media-grid-controller.test.ts index 0a902138..e1daa880 100644 --- a/tests/components-lib/media-grid-controller.test.ts +++ b/tests/components-lib/media-grid-controller.test.ts @@ -1,11 +1,12 @@ import Masonry from 'masonry-layout'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { MediaLoadedInfo } from '../../src/types'; import { + ExtendedMasonry, MediaGridConstructorOptions, MediaGridController, } from '../../src/components-lib/media-grid-controller'; +import { MediaLoadedInfo } from '../../src/types'; import { dispatchExistingMediaLoadedInfoAsEvent } from '../../src/utils/media-info'; import { MutationObserverMock, @@ -18,7 +19,7 @@ vi.mock('lodash-es/throttle', () => ({ default: vi.fn((fn) => fn), })); -const masonry = mock(); +const masonry = mock(); vi.mock('masonry-layout', () => ({ default: vi.fn().mockImplementation(() => { return masonry; @@ -85,6 +86,8 @@ describe('MediaGridController', () => { vi.clearAllMocks(); vi.stubGlobal('MutationObserver', MutationObserverMock); vi.stubGlobal('ResizeObserver', ResizeObserverMock); + + masonry.items = []; }); it('should be constructable', () => { @@ -526,4 +529,63 @@ describe('MediaGridController', () => { expect(Masonry).not.toBeCalled(); expect(masonry.layout).not.toBeCalled(); }); + + describe('describe should sort grid elements correctly', () => { + it('should respect placement when grid_selected_position is default', () => { + const children = createChildren(); + const parent = createParent({ children: children }); + + // Simulate wrapped children in masonry object. + masonry.items = children.map((child) => ({ element: child })); + + const controller = createController(parent); + controller.setDisplayConfig({ mode: 'grid', grid_selected_position: 'default' }); + + controller.selectCell('1'); + + expect(masonry.items).toEqual([ + { element: children[0] }, + { element: children[1] }, + { element: children[2] }, + ]); + }); + + it('should respect placement when grid_selected_position is first', () => { + const children = createChildren(['0', '1', '2']); + const parent = createParent({ children: children }); + + // Simulate wrapped children in masonry object. + masonry.items = children.map((child) => ({ element: child })); + + const controller = createController(parent); + controller.setDisplayConfig({ mode: 'grid', grid_selected_position: 'first' }); + + controller.selectCell('1'); + + expect(masonry.items).toEqual([ + { element: children[1] }, + { element: children[0] }, + { element: children[2] }, + ]); + }); + + it('should respect placement when grid_selected_position is last', () => { + const children = createChildren(['0', '1', '2']); + const parent = createParent({ children: children }); + + // Simulate wrapped children in masonry object. + masonry.items = children.map((child) => ({ element: child })); + + const controller = createController(parent); + controller.setDisplayConfig({ mode: 'grid', grid_selected_position: 'last' }); + + controller.selectCell('1'); + + expect(masonry.items).toEqual([ + { element: children[0] }, + { element: children[2] }, + { element: children[1] }, + ]); + }); + }); });