feat: Allow control of where selected item is in grid (#1979)

- Closes: #1946
This commit is contained in:
Dermot Duffy
2025-03-23 14:49:47 -07:00
committed by GitHub
parent 8512df1720
commit f140f6a653
14 changed files with 226 additions and 32 deletions
+23 -4
View File
@@ -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.
+10 -6
View File
@@ -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:
+10 -6
View File
@@ -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:
+43 -5
View File
@@ -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<GridID, MediaLoadedInfo> = 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();
}
+3
View File
@@ -3,9 +3,12 @@ import { z } from 'zod';
export const viewDisplayModeSchema = z.enum(['single', 'grid']);
export type ViewDisplayMode = z.infer<typeof viewDisplayModeSchema>;
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(),
+4
View File
@@ -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;
+28 -4
View File
@@ -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,
+6
View File
@@ -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"
},
+11 -5
View File
@@ -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"
}
+6
View File
@@ -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"
},
+6
View File
@@ -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": ""
},
+6
View File
@@ -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": ""
},
+6
View File
@@ -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": ""
},
@@ -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<Masonry>();
const masonry = mock<ExtendedMasonry>();
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] },
]);
});
});
});