Compare commits

...
Author SHA1 Message Date
Paul Bottein 4189b61cda Use the i18n context in the safe area tool row 2026-10-08 15:14:29 +02:00
Paul Bottein 1d2b8a9c84 Add a debug tool to simulate safe areas 2026-10-08 15:03:33 +02:00
6 changed files with 489 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
export interface DebugSafeArea {
top: number;
right: number;
bottom: number;
left: number;
highlight: boolean;
}
export const DEBUG_SAFE_AREA_TOOL_STORAGE_KEY = "debugSafeAreaTool";
export const DEBUG_SAFE_AREA_STORAGE_KEY = "debugSafeArea";
const STYLE_ID = "ha-debug-safe-area";
export const applyDebugSafeArea = (config?: DebugSafeArea): void => {
let style = document.getElementById(STYLE_ID);
if (
!config ||
!(config.top || config.right || config.bottom || config.left)
) {
style?.remove();
return;
}
if (!style) {
style = document.createElement("style");
style.id = STYLE_ID;
document.head.append(style);
}
const { top, right, bottom, left, highlight } = config;
// Important wins over the insets the companion app sets
style.textContent = `
:root {
--app-safe-area-inset-top: ${top}px !important;
--app-safe-area-inset-right: ${right}px !important;
--app-safe-area-inset-bottom: ${bottom}px !important;
--app-safe-area-inset-left: ${left}px !important;
}
${
highlight
? `html::after {
content: "";
position: fixed;
inset: 0;
box-sizing: border-box;
border: solid rgba(255, 0, 0, 0.25);
border-width: ${top}px ${right}px ${bottom}px ${left}px;
pointer-events: none;
z-index: 2147483646;
}`
: ""
}
`;
};
+343
View File
@@ -0,0 +1,343 @@
import type { ContextType } from "@lit/context";
import { mdiClose, mdiCropFree } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { consume } from "../common/decorators/consume";
import { storage } from "../common/decorators/storage";
import type { DebugSafeArea } from "../common/util/debug-safe-area";
import {
applyDebugSafeArea,
DEBUG_SAFE_AREA_STORAGE_KEY,
} from "../common/util/debug-safe-area";
import { internationalizationContext } from "../data/context";
import "./ha-button";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-switch";
import type { HaSwitch } from "./ha-switch";
import "./input/ha-input";
import type { HaInput } from "./input/ha-input";
type Side = "top" | "bottom" | "left" | "right";
const SIDES: Side[] = ["top", "bottom", "left", "right"];
const PRESETS = {
none: { top: 0, right: 0, bottom: 0, left: 0 },
portrait: { top: 62, right: 0, bottom: 34, left: 0 },
landscape: { top: 0, right: 62, bottom: 21, left: 62 },
} as const;
type Preset = keyof typeof PRESETS;
const BUBBLE_SIZE = 48;
const BUBBLE_MARGIN = 16;
const DRAG_THRESHOLD = 4;
const DEFAULT_SAFE_AREA: DebugSafeArea = {
...PRESETS.none,
highlight: true,
};
interface DragStart {
pointerX: number;
pointerY: number;
right: number;
bottom: number;
moved: boolean;
}
@customElement("ha-debug-safe-area-tool")
export class HaDebugSafeAreaTool extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@storage({ key: DEBUG_SAFE_AREA_STORAGE_KEY, state: true, subscribe: false })
private _config: DebugSafeArea = DEFAULT_SAFE_AREA;
@state() private _open = false;
@state() private _position = { right: BUBBLE_MARGIN, bottom: BUBBLE_MARGIN };
private _dragStart?: DragStart;
private _dragged = false;
public connectedCallback() {
super.connectedCallback();
applyDebugSafeArea(this._config);
}
public disconnectedCallback() {
super.disconnectedCallback();
applyDebugSafeArea(undefined);
}
protected render() {
if (!this._i18n) {
return nothing;
}
const { localize } = this._i18n;
const { right, bottom } = this._position;
return html`
<div
class=${classMap({
tool: true,
above: bottom < window.innerHeight / 2,
end: right < window.innerWidth / 2,
})}
style=${styleMap({
right: `clamp(0px, ${right}px, calc(100vw - ${BUBBLE_SIZE}px))`,
bottom: `clamp(0px, ${bottom}px, calc(100vh - ${BUBBLE_SIZE}px))`,
})}
>
<button
class="bubble"
type="button"
aria-label=${localize("ui.components.safe_area_tool.title")}
aria-expanded=${this._open}
@pointerdown=${this._pointerDown}
@pointermove=${this._pointerMove}
@pointerup=${this._pointerUp}
@click=${this._toggleOpen}
>
<ha-svg-icon .path=${mdiCropFree}></ha-svg-icon>
</button>
${
this._open
? html`
<div class="panel">
<div class="header">
<span
>${localize("ui.components.safe_area_tool.title")}</span
>
<ha-icon-button
.label=${localize("ui.common.close")}
.path=${mdiClose}
@click=${this._toggleOpen}
></ha-icon-button>
</div>
<div class="presets">
${(Object.keys(PRESETS) as Preset[]).map(
(preset) => html`
<ha-button
size="s"
appearance="outlined"
data-preset=${preset}
@click=${this._applyPreset}
>
${localize(
`ui.components.safe_area_tool.presets.${preset}`
)}
</ha-button>
`
)}
</div>
<div class="sides">
<div class="screen" aria-hidden="true"></div>
${SIDES.map(
(side) => html`
<ha-input
class=${side}
appearance="outlined"
type="number"
min="0"
without-spin-buttons
.label=${localize(
`ui.components.safe_area_tool.${side}`
)}
data-side=${side}
.value=${String(this._config[side])}
@change=${this._sideChanged}
></ha-input>
`
)}
</div>
<label class="highlight">
${localize("ui.components.safe_area_tool.highlight")}
<ha-switch
.checked=${this._config.highlight}
@change=${this._highlightChanged}
></ha-switch>
</label>
</div>
`
: nothing
}
</div>
`;
}
private _update(config: DebugSafeArea) {
this._config = config;
applyDebugSafeArea(config);
}
private _applyPreset(ev: Event) {
const preset = (ev.currentTarget as HTMLElement).dataset.preset as Preset;
this._update({ ...this._config, ...PRESETS[preset] });
}
private _sideChanged(ev: Event) {
const input = ev.currentTarget as HaInput;
const side = input.dataset.side as Side;
const value = Math.max(0, Number(input.value) || 0);
this._update({ ...this._config, [side]: value });
}
private _highlightChanged(ev: Event) {
this._update({
...this._config,
highlight: (ev.currentTarget as HaSwitch).checked,
});
}
private _toggleOpen() {
if (this._dragged) {
this._dragged = false;
return;
}
this._open = !this._open;
}
private _pointerDown(ev: PointerEvent) {
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
this._dragStart = {
pointerX: ev.clientX,
pointerY: ev.clientY,
...this._position,
moved: false,
};
}
private _pointerMove(ev: PointerEvent) {
const start = this._dragStart;
if (!start) {
return;
}
const dx = ev.clientX - start.pointerX;
const dy = ev.clientY - start.pointerY;
if (!start.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) {
return;
}
start.moved = true;
this._position = {
right: Math.min(
Math.max(start.right - dx, 0),
window.innerWidth - BUBBLE_SIZE
),
bottom: Math.min(
Math.max(start.bottom - dy, 0),
window.innerHeight - BUBBLE_SIZE
),
};
}
private _pointerUp() {
this._dragged = !!this._dragStart?.moved;
this._dragStart = undefined;
}
static styles = css`
.tool {
position: fixed;
z-index: 2147483647;
}
.bubble {
display: flex;
align-items: center;
justify-content: center;
width: ${BUBBLE_SIZE}px;
height: ${BUBBLE_SIZE}px;
padding: 0;
border: none;
border-radius: var(--ha-border-radius-circle);
background-color: var(--error-color);
color: var(--text-primary-color);
box-shadow: var(--ha-box-shadow-m);
cursor: grab;
touch-action: none;
}
.bubble:active {
cursor: grabbing;
}
.panel {
position: absolute;
top: calc(100% + var(--ha-space-2));
left: 0;
box-sizing: border-box;
width: 300px;
padding: var(--ha-space-2) var(--ha-space-3) var(--ha-space-3);
border-radius: var(--ha-border-radius-lg);
background-color: var(--card-background-color);
color: var(--primary-text-color);
box-shadow: var(--ha-box-shadow-l);
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.above .panel {
top: auto;
bottom: calc(100% + var(--ha-space-2));
}
.end .panel {
left: auto;
right: 0;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: var(--ha-font-weight-medium);
}
.presets {
display: flex;
gap: var(--ha-space-2);
}
.sides {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-areas:
". top ."
"left screen right"
". bottom .";
align-items: center;
gap: var(--ha-space-2);
}
.screen {
grid-area: screen;
align-self: stretch;
min-height: 64px;
border: 2px dashed var(--divider-color);
border-radius: var(--ha-border-radius-md);
}
.top {
grid-area: top;
}
.bottom {
grid-area: bottom;
}
.left {
grid-area: left;
}
.right {
grid-area: right;
}
.highlight {
display: flex;
align-items: center;
justify-content: space-between;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-debug-safe-area-tool": HaDebugSafeAreaTool;
}
}
+18
View File
@@ -5,6 +5,8 @@ import { customElement, property, state } from "lit/decorators";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { listenMediaQuery } from "../common/dom/media_query";
import { storage } from "../common/decorators/storage";
import { DEBUG_SAFE_AREA_TOOL_STORAGE_KEY } from "../common/util/debug-safe-area";
import { computeRTLDirection } from "../common/util/compute_rtl";
import "../components/ha-drawer";
import { narrowViewportContext } from "../data/context";
@@ -37,6 +39,13 @@ export class HomeAssistantMain extends LitElement {
@state() private _drawerOpen = false;
@storage({
key: DEBUG_SAFE_AREA_TOOL_STORAGE_KEY,
state: true,
subscribe: true,
})
private _debugSafeAreaTool = false;
private _narrowViewportProvider = new ContextProvider(this, {
context: narrowViewportContext,
initialValue: this.narrow,
@@ -80,6 +89,11 @@ export class HomeAssistantMain extends LitElement {
: nothing
}
</ha-drawer>
${
this._debugSafeAreaTool
? html`<ha-debug-safe-area-tool></ha-debug-safe-area-tool>`
: nothing
}
`;
}
@@ -135,6 +149,10 @@ export class HomeAssistantMain extends LitElement {
if (changedProps.has("route") && this._sidebarNarrow) {
this._drawerOpen = false;
}
if (this._debugSafeAreaTool) {
import("../components/ha-debug-safe-area-tool");
}
}
protected updated(changedProps: PropertyValues<this>) {
@@ -0,0 +1,56 @@
import type { ContextType } from "@lit/context";
import { html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { consume } from "../../../../common/decorators/consume";
import { storage } from "../../../../common/decorators/storage";
import { DEBUG_SAFE_AREA_TOOL_STORAGE_KEY } from "../../../../common/util/debug-safe-area";
import "../../../../components/ha-switch";
import type { HaSwitch } from "../../../../components/ha-switch";
import "../../../../components/item/ha-list-item-base";
import { internationalizationContext } from "../../../../data/context";
@customElement("ha-debug-safe-area-tool-row")
class HaDebugSafeAreaToolRow extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@storage({ key: DEBUG_SAFE_AREA_TOOL_STORAGE_KEY, state: true })
private _enabled = false;
protected render() {
if (!this._i18n) {
return nothing;
}
const { localize } = this._i18n;
return html`
<ha-list-item-base>
<span slot="headline"
>${localize(
"ui.panel.config.tools.tabs.debug.safe_area_tool.title"
)}</span
>
<span slot="supporting-text"
>${localize(
"ui.panel.config.tools.tabs.debug.safe_area_tool.description"
)}</span
>
<ha-switch
slot="end"
.checked=${this._enabled}
@change=${this._checkedChanged}
></ha-switch>
</ha-list-item-base>
`;
}
private _checkedChanged(ev: Event) {
this._enabled = (ev.target as HaSwitch).checked;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-debug-safe-area-tool-row": HaDebugSafeAreaToolRow;
}
}
@@ -16,6 +16,7 @@ import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import "./ha-debug-connection-row";
import "./ha-debug-disable-view-transition-row";
import "./ha-debug-safe-area-tool-row";
import "./ha-debug-viewport-environment-card";
@customElement("tools-debug")
@@ -35,6 +36,7 @@ class HaPanelDevDebug extends SubscribeMixin(LitElement) {
)}
>
<ha-list-base>
<ha-debug-safe-area-tool-row></ha-debug-safe-area-tool-row>
<ha-debug-connection-row
.hass=${this.hass}
></ha-debug-connection-row>
+17
View File
@@ -529,6 +529,19 @@
"last": "Last"
},
"components": {
"safe_area_tool": {
"title": "Safe area",
"presets": {
"none": "None",
"portrait": "Portrait",
"landscape": "Landscape"
},
"top": "Top",
"bottom": "Bottom",
"left": "Left",
"right": "Right",
"highlight": "Highlight"
},
"input": {
"clear": "Clear",
"show_password": "Show password",
@@ -4055,6 +4068,10 @@
"title": "Debug connection",
"description": "Observe requests to the server and responses from the server in browser console."
},
"safe_area_tool": {
"title": "Debug safe area",
"description": "Show a floating tool to simulate the parts of the screen a device can't use, like a notch or a camera cutout. Only applies to this browser."
},
"disable_view_transition": {
"title": "Disable view transitions",
"description": "Disable animated view transitions to prevent browser crash when using browser developer tools."