diff --git a/package.json b/package.json index 97b87ee55f..06242b1c75 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "@material/mwc-top-app-bar": "0.27.0", "@material/mwc-top-app-bar-fixed": "0.27.0", "@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0", - "@material/web": "=1.0.0-pre.8", + "@material/web": "=1.0.0-pre.9", "@mdi/js": "7.2.96", "@mdi/svg": "7.2.96", "@polymer/app-layout": "3.1.0", diff --git a/src/common/color/convert-light-color.ts b/src/common/color/convert-light-color.ts new file mode 100644 index 0000000000..3e67b00fcb --- /dev/null +++ b/src/common/color/convert-light-color.ts @@ -0,0 +1,106 @@ +import { clamp } from "../number/clamp"; + +const DEFAULT_MIN_KELVIN = 2700; +const DEFAULT_MAX_KELVIN = 6500; + +export const temperature2rgb = ( + temperature: number +): [number, number, number] => { + const value = temperature / 100; + return [ + temperatureRed(value), + temperatureGreen(value), + temperatureBlue(value), + ]; +}; + +const temperatureRed = (temperature: number): number => { + if (temperature <= 66) { + return 255; + } + const red = 329.698727446 * (temperature - 60) ** -0.1332047592; + return clamp(red, 0, 255); +}; + +const temperatureGreen = (temperature: number): number => { + let green: number; + if (temperature <= 66) { + green = 99.4708025861 * Math.log(temperature) - 161.1195681661; + } else { + green = 288.1221695283 * (temperature - 60) ** -0.0755148492; + } + return clamp(green, 0, 255); +}; + +const temperatureBlue = (temperature: number): number => { + if (temperature >= 66) { + return 255; + } + if (temperature <= 19) { + return 0; + } + const blue = 138.5177312231 * Math.log(temperature - 10) - 305.0447927307; + return clamp(blue, 0, 255); +}; + +const matchMaxScale = ( + inputColors: number[], + outputColors: number[] +): number[] => { + const maxIn: number = Math.max(...inputColors); + const maxOut: number = Math.max(...outputColors); + let factor: number; + if (maxOut === 0) { + factor = 0.0; + } else { + factor = maxIn / maxOut; + } + return outputColors.map((value) => Math.round(value * factor)); +}; + +const mired2kelvin = (miredTemperature: number) => + Math.floor(1000000 / miredTemperature); + +const kelvin2mired = (kelvintTemperature: number) => + Math.floor(1000000 / kelvintTemperature); + +export const rgbww2rgb = ( + rgbww: [number, number, number, number, number], + minKelvin?: number, + maxKelvin?: number +): [number, number, number] => { + const [r, g, b, cw, ww] = rgbww; + // Calculate color temperature of the white channels + const maxMireds: number = kelvin2mired(minKelvin ?? DEFAULT_MIN_KELVIN); + const minMireds: number = kelvin2mired(maxKelvin ?? DEFAULT_MAX_KELVIN); + const miredRange: number = maxMireds - minMireds; + let ctRatio: number; + try { + ctRatio = ww / (cw + ww); + } catch (_error) { + ctRatio = 0.5; + } + const colorTempMired = minMireds + ctRatio * miredRange; + const colorTempKelvin = colorTempMired ? mired2kelvin(colorTempMired) : 0; + const [wR, wG, wB] = temperature2rgb(colorTempKelvin); + const whiteLevel = Math.max(cw, ww) / 255; + + // Add the white channels to the rgb channels. + const rgb = [ + r + wR * whiteLevel, + g + wG * whiteLevel, + b + wB * whiteLevel, + ] as [number, number, number]; + + // Match the output maximum value to the input. This ensures the + // output doesn't overflow. + return matchMaxScale([r, g, b, cw, ww], rgb) as [number, number, number]; +}; + +export const rgbw2rgb = ( + rgbw: [number, number, number, number] +): [number, number, number] => { + const [r, g, b, w] = rgbw; + const rgb = [r + w, g + w, b + w] as [number, number, number]; + return matchMaxScale([r, g, b, w], rgb) as [number, number, number]; +}; diff --git a/src/components/ha-hs-color-picker.ts b/src/components/ha-hs-color-picker.ts index 33fa5977f2..a0b86fa6a5 100644 --- a/src/components/ha-hs-color-picker.ts +++ b/src/components/ha-hs-color-picker.ts @@ -4,6 +4,7 @@ import { customElement, property, query, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { styleMap } from "lit/directives/style-map"; import { hsv2rgb, rgb2hex } from "../common/color/convert-color"; +import { rgbw2rgb, rgbww2rgb } from "../common/color/convert-light-color"; import { fireEvent } from "../common/dom/fire_event"; function xy2polar(x: number, y: number) { @@ -26,7 +27,36 @@ function deg2rad(deg: number) { return (deg / 360) * 2 * Math.PI; } -function drawColorWheel(ctx: CanvasRenderingContext2D, colorBrightness = 255) { +function adjustRgb( + rgb: [number, number, number], + wv?: number, + cw?: number, + ww?: number, + minKelvin?: number, + maxKelvin?: number +) { + if (wv != null) { + return rgbw2rgb([...rgb, wv] as [number, number, number, number]); + } + if (cw != null && ww !== null) { + return rgbww2rgb( + [...rgb, cw, ww] as [number, number, number, number, number], + minKelvin, + maxKelvin + ); + } + return rgb; +} + +function drawColorWheel( + ctx: CanvasRenderingContext2D, + colorBrightness = 255, + wv?: number, + cw?: number, + ww?: number, + minKelvin?: number, + maxKelvin?: number +) { const radius = ctx.canvas.width / 2; ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); @@ -44,8 +74,26 @@ function drawColorWheel(ctx: CanvasRenderingContext2D, colorBrightness = 255) { ctx.closePath(); const gradient = ctx.createRadialGradient(cX, cY, 0, cX, cY, radius); - const start = rgb2hex(hsv2rgb([angle, 0, colorBrightness])); - const end = rgb2hex(hsv2rgb([angle, 1, colorBrightness])); + const start = rgb2hex( + adjustRgb( + hsv2rgb([angle, 0, colorBrightness]), + wv, + cw, + ww, + minKelvin, + maxKelvin + ) + ); + const end = rgb2hex( + adjustRgb( + hsv2rgb([angle, 1, colorBrightness]), + wv, + cw, + ww, + minKelvin, + maxKelvin + ) + ); gradient.addColorStop(0, start); gradient.addColorStop(1, end); ctx.fillStyle = gradient; @@ -67,6 +115,21 @@ class HaHsColorPicker extends LitElement { @property({ type: Number }) public colorBrightness?: number; + @property({ type: Number }) + public wv?: number; + + @property({ type: Number }) + public cw?: number; + + @property({ type: Number }) + public ww?: number; + + @property({ type: Number }) + public minKelvin?: number; + + @property({ type: Number }) + public maxKelvin?: number; + @query("#canvas") private _canvas!: HTMLCanvasElement; private _mc?: HammerManager; @@ -88,7 +151,15 @@ class HaHsColorPicker extends LitElement { private _generateColorWheel() { const ctx = this._canvas.getContext("2d")!; - drawColorWheel(ctx, this.colorBrightness); + drawColorWheel( + ctx, + this.colorBrightness, + this.wv, + this.cw, + this.ww, + this.minKelvin, + this.maxKelvin + ); } connectedCallback(): void { @@ -103,7 +174,14 @@ class HaHsColorPicker extends LitElement { protected updated(changedProps: PropertyValues): void { super.updated(changedProps); - if (changedProps.has("colorBrightness")) { + if ( + changedProps.has("colorBrightness") || + changedProps.has("vw") || + changedProps.has("ww") || + changedProps.has("cw") || + changedProps.has("minKelvin") || + changedProps.has("maxKelvin") + ) { this._generateColorWheel(); } if (changedProps.has("value")) { @@ -221,11 +299,16 @@ class HaHsColorPicker extends LitElement { const rgb = this._localValue !== undefined - ? hsv2rgb([ - this._localValue[0], - this._localValue[1], - this.colorBrightness ?? 255, - ]) + ? adjustRgb( + hsv2rgb([ + this._localValue[0], + this._localValue[1], + this.colorBrightness ?? 255, + ]), + this.wv, + this.cw, + this.ww + ) : ([255, 255, 255] as [number, number, number]); const [x, y] = this._cursorPosition ?? [0, 0]; diff --git a/src/components/ha-temp-color-picker.ts b/src/components/ha-temp-color-picker.ts index ddc97b53ed..8e1309b0a9 100644 --- a/src/components/ha-temp-color-picker.ts +++ b/src/components/ha-temp-color-picker.ts @@ -4,6 +4,7 @@ import { customElement, property, query, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { styleMap } from "lit/directives/style-map"; import { rgb2hex } from "../common/color/convert-color"; +import { temperature2rgb } from "../common/color/convert-light-color"; import { fireEvent } from "../common/dom/fire_event"; declare global { @@ -12,6 +13,17 @@ declare global { } } +const A11Y_KEY_CODES = new Set([ + "ArrowRight", + "ArrowUp", + "ArrowLeft", + "ArrowDown", + "PageUp", + "PageDown", + "Home", + "End", +]); + function xy2polar(x: number, y: number) { const r = Math.sqrt(x * x + y * y); const phi = Math.atan2(y, x); @@ -24,44 +36,6 @@ function polar2xy(r: number, phi: number) { return [x, y]; } -function temperature2rgb(temperature: number): [number, number, number] { - const value = temperature / 100; - return [getRed(value), getGreen(value), getBlue(value)]; -} - -function getRed(temperature: number): number { - if (temperature <= 66) { - return 255; - } - const tmp_red = 329.698727446 * (temperature - 60) ** -0.1332047592; - return clamp(tmp_red); -} - -function getGreen(temperature: number): number { - let green: number; - if (temperature <= 66) { - green = 99.4708025861 * Math.log(temperature) - 161.1195681661; - } else { - green = 288.1221695283 * (temperature - 60) ** -0.0755148492; - } - return clamp(green); -} - -function getBlue(temperature: number): number { - if (temperature >= 66) { - return 255; - } - if (temperature <= 19) { - return 0; - } - const blue = 138.5177312231 * Math.log(temperature - 10) - 305.0447927307; - return clamp(blue); -} - -function clamp(value: number): number { - return Math.max(0, Math.min(255, value)); -} - function drawColorWheel( ctx: CanvasRenderingContext2D, minTemp: number, @@ -99,9 +73,11 @@ class HaTempColorPicker extends LitElement { @property({ type: Number }) public value?: number; - @property() min = 2000; + @property({ type: Number }) + public min = 2000; - @property() max = 10000; + @property({ type: Number }) + public max = 10000; @query("#canvas") private _canvas!: HTMLCanvasElement; @@ -120,6 +96,11 @@ class HaTempColorPicker extends LitElement { super.firstUpdated(changedProps); this._setupListeners(); this._generateColorWheel(); + this.setAttribute("role", "slider"); + this.setAttribute("aria-orientation", "vertical"); + if (!this.hasAttribute("tabindex")) { + this.setAttribute("tabindex", "0"); + } } private _generateColorWheel() { @@ -139,18 +120,27 @@ class HaTempColorPicker extends LitElement { protected updated(changedProps: PropertyValues): void { super.updated(changedProps); + if (changedProps.has("_localValue")) { + this.setAttribute("aria-valuenow", this._localValue?.toString() ?? ""); + } if (changedProps.has("min") || changedProps.has("max")) { this._generateColorWheel(); this._resetPosition(); } + if (changedProps.has("min")) { + this.setAttribute("aria-valuemin", this.min.toString()); + } + if (changedProps.has("max")) { + this.setAttribute("aria-valuemax", this.max.toString()); + } if (changedProps.has("value")) { - if (this.value !== undefined && this._localValue !== this.value) { + if (this.value != null && this._localValue !== this.value) { this._resetPosition(); } } } - _setupListeners() { + private _setupListeners() { if (this._canvas && !this._mc) { this._mc = new Manager(this._canvas); this._mc.add( @@ -195,6 +185,9 @@ class HaTempColorPicker extends LitElement { this._localValue = this._getValueFromCoord(...this._cursorPosition); fireEvent(this, "value-changed", { value: this._localValue }); }); + + this.addEventListener("keydown", this._handleKeyDown); + this.addEventListener("keyup", this._handleKeyUp); } } @@ -237,21 +230,74 @@ class HaTempColorPicker extends LitElement { return [__x, __y]; }; - _destroyListeners() { + private _destroyListeners() { if (this._mc) { this._mc.destroy(); this._mc = undefined; } + this.removeEventListener("keydown", this._handleKeyDown); + this.removeEventListener("keyup", this._handleKeyDown); + } + + _handleKeyDown(e: KeyboardEvent) { + if (!A11Y_KEY_CODES.has(e.code)) return; + e.preventDefault(); + + const step = 1; + const tenPercentStep = Math.max(step, (this.max - this.min) / 10); + const currentValue = + this._localValue ?? Math.round((this.max + this.min) / 2); + switch (e.code) { + case "ArrowRight": + case "ArrowUp": + this._localValue = Math.round(Math.min(currentValue + step, this.max)); + break; + case "ArrowLeft": + case "ArrowDown": + this._localValue = Math.round(Math.max(currentValue - step, this.min)); + break; + case "PageUp": + this._localValue = Math.round( + Math.min(currentValue + tenPercentStep, this.max) + ); + break; + case "PageDown": + this._localValue = Math.round( + Math.max(currentValue - tenPercentStep, this.min) + ); + break; + case "Home": + this._localValue = this.min; + break; + case "End": + this._localValue = this.max; + break; + } + if (this._localValue != null) { + const [_, y] = this._getCoordsFromValue(this._localValue); + const currentX = this._cursorPosition?.[0] ?? 0; + const x = + Math.sign(currentX) * + Math.min(Math.sqrt(1 - y ** 2), Math.abs(currentX)); + this._cursorPosition = [x, y]; + fireEvent(this, "cursor-moved", { value: this._localValue }); + } + } + + _handleKeyUp(e: KeyboardEvent) { + if (!A11Y_KEY_CODES.has(e.code)) return; + e.preventDefault(); + this.value = this._localValue; + fireEvent(this, "value-changed", { value: this._localValue }); } render() { const size = this.renderSize || 400; const canvasSize = size * window.devicePixelRatio; - const rgb = - this._localValue !== undefined - ? temperature2rgb(this._localValue) - : ([255, 255, 255] as [number, number, number]); + const rgb = temperature2rgb( + this._localValue ?? Math.round((this.max + this.min) / 2) + ); const [x, y] = this._cursorPosition ?? [0, 0]; @@ -266,7 +312,12 @@ class HaTempColorPicker extends LitElement { return html`
- +
diff --git a/src/dialogs/more-info/components/lights/dialog-light-color-favorite.ts b/src/dialogs/more-info/components/lights/dialog-light-color-favorite.ts new file mode 100644 index 0000000000..39a6ce6cc1 --- /dev/null +++ b/src/dialogs/more-info/components/lights/dialog-light-color-favorite.ts @@ -0,0 +1,125 @@ +import { mdiClose } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-button"; +import "../../../../components/ha-dialog"; +import "../../../../components/ha-dialog-header"; +import { EntityRegistryEntry } from "../../../../data/entity_registry"; +import { LightColor } from "../../../../data/light"; +import { haStyleDialog } from "../../../../resources/styles"; +import { HomeAssistant } from "../../../../types"; +import "./light-color-picker"; +import { LightColorFavoriteDialogParams } from "./show-dialog-light-color-favorite"; + +@customElement("dialog-light-color-favorite") +class DialogLightColorFavorite extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() _dialogParams?: LightColorFavoriteDialogParams; + + @state() _entry?: EntityRegistryEntry; + + @state() _color?: LightColor; + + public async showDialog( + dialogParams: LightColorFavoriteDialogParams + ): Promise { + this._entry = dialogParams.entry; + this._dialogParams = dialogParams; + await this.updateComplete; + } + + public closeDialog(): void { + this._dialogParams = undefined; + this._entry = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + private _colorChanged(ev: CustomEvent) { + this._color = ev.detail; + } + + private async _cancel() { + this._dialogParams?.cancel?.(); + this.closeDialog(); + } + + private async _save() { + if (!this._color) { + this._cancel(); + return; + } + this._dialogParams?.submit?.(this._color); + this.closeDialog(); + } + + protected render() { + if (!this._entry) { + return nothing; + } + + const title = this.hass.localize("ui.dialogs.light-color-favorite.title"); + + return html` + + + + ${title} + + + + + ${this.hass.localize("ui.common.cancel")} + + ${this.hass.localize("ui.common.save")} + + `; + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + ha-dialog { + --dialog-content-padding: 0; + } + + light-color-picker { + display: flex; + flex-direction: column; + flex: 1; + } + + @media all and (max-width: 450px), all and (max-height: 500px) { + ha-dialog { + --dialog-surface-margin-top: 100px; + --mdc-dialog-min-height: calc(100% - 100px); + --mdc-dialog-max-height: calc(100% - 100px); + --ha-dialog-border-radius: var( + --ha-dialog-bottom-sheet-border-radius, + 28px 28px 0 0 + ); + } + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-light-color-favorite": DialogLightColorFavorite; + } +} diff --git a/src/dialogs/more-info/components/lights/ha-favorite-color-button.ts b/src/dialogs/more-info/components/lights/ha-favorite-color-button.ts new file mode 100644 index 0000000000..c5138da644 --- /dev/null +++ b/src/dialogs/more-info/components/lights/ha-favorite-color-button.ts @@ -0,0 +1,121 @@ +import { mdiPencil } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property, query } from "lit/decorators"; +import { ifDefined } from "lit/directives/if-defined"; +import { styleMap } from "lit/directives/style-map"; +import { hs2rgb, rgb2hex } from "../../../../common/color/convert-color"; +import { + rgbw2rgb, + rgbww2rgb, + temperature2rgb, +} from "../../../../common/color/convert-light-color"; +import { luminosity } from "../../../../common/color/rgb"; +import { HaOutlinedIconButton } from "../../../../components/ha-outlined-icon-button"; +import "../../../../components/ha-svg-icon"; +import { LightColor, LightEntity } from "../../../../data/light"; + +@customElement("ha-favorite-color-button") +class MoreInfoViewLightColorPicker extends LitElement { + public override focus() { + this._button?.focus(); + } + + @property({ attribute: false }) label?: string; + + @property({ type: Boolean, reflect: true }) disabled = false; + + @property() stateObj?: LightEntity; + + @property() color!: LightColor; + + @property() editMode?: boolean; + + @query("ha-outlined-icon-button", true) + private _button?: HaOutlinedIconButton; + + private get _rgbColor(): [number, number, number] { + if ("hs_color" in this.color) { + return hs2rgb([this.color.hs_color[0], this.color.hs_color[1] / 100]); + } + if ("color_temp_kelvin" in this.color) { + return temperature2rgb(this.color.color_temp_kelvin); + } + if ("rgb_color" in this.color) { + return this.color.rgb_color; + } + if ("rgbw_color" in this.color) { + return rgbw2rgb(this.color.rgbw_color); + } + if ("rgbww_color" in this.color) { + return rgbww2rgb( + this.color.rgbww_color, + this.stateObj?.attributes.min_color_temp_kelvin, + this.stateObj?.attributes.max_color_temp_kelvin + ); + } + return [255, 255, 255]; + } + + protected render() { + const backgroundColor = rgb2hex(this._rgbColor); + const isLight = luminosity(this._rgbColor) > 0.8; + const iconColor = isLight + ? ([33, 33, 33] as [number, number, number]) + : ([255, 255, 255] as [number, number, number]); + const hexIconColor = rgb2hex(iconColor); + const rgbIconColor = iconColor.join(", "); + + return html` + + ${this.editMode + ? html`` + : nothing} + + `; + } + + static get styles(): CSSResultGroup { + return [ + css` + ha-outlined-icon-button { + --ha-icon-display: block; + --md-sys-color-on-surface: var( + --icon-color, + var(--secondary-text-color) + ); + --md-sys-color-on-surface-variant: var( + --icon-color, + var(--secondary-text-color) + ); + --md-sys-color-on-surface-rgb: var( + --rgb-icon-color, + var(--rgb-secondary-text-color) + ); + --md-sys-color-outline: var(--divider-color); + border-radius: 9999px; + } + :host([disabled]) { + pointer-events: none; + } + ha-outlined-icon-button[disabled] { + filter: grayscale(1) opacity(0.5); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-favorite-color-button": MoreInfoViewLightColorPicker; + } +} diff --git a/src/dialogs/more-info/components/lights/ha-more-info-view-light-color-picker.ts b/src/dialogs/more-info/components/lights/ha-more-info-view-light-color-picker.ts index c17e116536..42b7040c57 100644 --- a/src/dialogs/more-info/components/lights/ha-more-info-view-light-color-picker.ts +++ b/src/dialogs/more-info/components/lights/ha-more-info-view-light-color-picker.ts @@ -1,536 +1,39 @@ -import "@material/mwc-button"; -import "@material/mwc-tab-bar/mwc-tab-bar"; -import "@material/mwc-tab/mwc-tab"; -import { - css, - CSSResultGroup, - html, - LitElement, - nothing, - PropertyValues, -} from "lit"; -import { customElement, property, state } from "lit/decorators"; -import { hs2rgb, rgb2hs } from "../../../../common/color/convert-color"; -import { throttle } from "../../../../common/util/throttle"; -import "../../../../components/ha-button-toggle-group"; -import "../../../../components/ha-hs-color-picker"; -import "../../../../components/ha-icon-button-prev"; -import "../../../../components/ha-labeled-slider"; -import "../../../../components/ha-temp-color-picker"; -import { - getLightCurrentModeRgbColor, - LightColorMode, - LightEntity, - lightSupportsColor, - lightSupportsColorMode, -} from "../../../../data/light"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; import { HomeAssistant } from "../../../../types"; +import "./light-color-picker"; import { LightColorPickerViewParams } from "./show-view-light-color-picker"; -type Mode = "color_temp" | "color"; - @customElement("ha-more-info-view-light-color-picker") class MoreInfoViewLightColorPicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property() public params?: LightColorPickerViewParams; - @state() private _cwSliderValue?: number; - - @state() private _wwSliderValue?: number; - - @state() private _wvSliderValue?: number; - - @state() private _colorBrightnessSliderValue?: number; - - @state() private _brightnessAdjusted?: number; - - @state() private _hsPickerValue?: [number, number]; - - @state() private _ctPickerValue?: number; - - @state() private _mode?: Mode; - - @state() private _modes: Mode[] = []; - - get stateObj() { - return this.params - ? (this.hass.states[this.params.entityId] as LightEntity) - : undefined; - } - protected render() { - if (!this.params || !this.stateObj) { + if (!this.params) { return nothing; } - const supportsRgbww = lightSupportsColorMode( - this.stateObj, - LightColorMode.RGBWW - ); - - const supportsRgbw = - !supportsRgbww && - lightSupportsColorMode(this.stateObj, LightColorMode.RGBW); - return html` - ${this._modes.length > 1 - ? html` - - ${this._modes.map( - (value) => - html`` - )} - - ` - : nothing} -
- ${this._mode === LightColorMode.COLOR_TEMP - ? html` -

- ${this._ctPickerValue ? `${this._ctPickerValue} K` : nothing} -

- - - ` - : nothing} - ${this._mode === "color" - ? html` - - - - ${supportsRgbw || supportsRgbww - ? html`` - : ""} - ${supportsRgbw - ? html` - - ` - : ""} - ${supportsRgbww - ? html` - - - ` - : nothing} - ` - : nothing} -
+ + `; } - public _updateSliderValues() { - const stateObj = this.stateObj; - - if (stateObj?.state === "on") { - this._brightnessAdjusted = undefined; - if ( - stateObj.attributes.color_mode === LightColorMode.RGB && - stateObj.attributes.rgb_color && - !lightSupportsColorMode(stateObj, LightColorMode.RGBWW) && - !lightSupportsColorMode(stateObj, LightColorMode.RGBW) - ) { - const maxVal = Math.max(...stateObj.attributes.rgb_color); - - if (maxVal < 255) { - this._brightnessAdjusted = maxVal; - } - } - this._ctPickerValue = - stateObj.attributes.color_mode === LightColorMode.COLOR_TEMP - ? stateObj.attributes.color_temp_kelvin - : undefined; - - this._wvSliderValue = - stateObj.attributes.color_mode === LightColorMode.RGBW && - stateObj.attributes.rgbw_color - ? Math.round((stateObj.attributes.rgbw_color[3] * 100) / 255) - : undefined; - this._cwSliderValue = - stateObj.attributes.color_mode === LightColorMode.RGBWW && - stateObj.attributes.rgbww_color - ? Math.round((stateObj.attributes.rgbww_color[3] * 100) / 255) - : undefined; - this._wwSliderValue = - stateObj.attributes.color_mode === LightColorMode.RGBWW && - stateObj.attributes.rgbww_color - ? Math.round((stateObj.attributes.rgbww_color[4] * 100) / 255) - : undefined; - - const currentRgbColor = getLightCurrentModeRgbColor(stateObj); - - this._colorBrightnessSliderValue = currentRgbColor - ? Math.round((Math.max(...currentRgbColor.slice(0, 3)) * 100) / 255) - : undefined; - - this._hsPickerValue = currentRgbColor - ? rgb2hs(currentRgbColor.slice(0, 3) as [number, number, number]) - : undefined; - } else { - this._hsPickerValue = [0, 0]; - this._ctPickerValue = undefined; - this._wvSliderValue = undefined; - this._cwSliderValue = undefined; - this._wwSliderValue = undefined; - } - } - - public willUpdate(changedProps: PropertyValues) { - super.willUpdate(changedProps); - - if (!changedProps.has("params") && !changedProps.has("hass")) { - return; - } - - if (changedProps.has("params")) { - const supportsTemp = lightSupportsColorMode( - this.stateObj!, - LightColorMode.COLOR_TEMP - ); - - const supportsColor = lightSupportsColor(this.stateObj!); - - const modes: Mode[] = []; - if (supportsColor) { - modes.push("color"); - } - if (supportsTemp) { - modes.push("color_temp"); - } - - this._modes = modes; - this._mode = this.stateObj!.attributes.color_mode - ? this.stateObj!.attributes.color_mode === LightColorMode.COLOR_TEMP - ? LightColorMode.COLOR_TEMP - : "color" - : this._modes[0]; - } - - this._updateSliderValues(); - } - - private _handleTabChanged(ev: CustomEvent): void { - const newMode = this._modes[ev.detail.index]; - if (newMode === this._mode) { - return; - } - this._mode = newMode; - } - - private _hsColorCursorMoved(ev: CustomEvent) { - if (!ev.detail.value) { - return; - } - this._hsPickerValue = ev.detail.value; - - this._throttleUpdateColor(); - } - - private _throttleUpdateColor = throttle(() => this._updateColor(), 500); - - private _updateColor() { - const hs_color = this._hsPickerValue!; - const rgb_color = hs2rgb(hs_color); - - if ( - lightSupportsColorMode(this.stateObj!, LightColorMode.RGBWW) || - lightSupportsColorMode(this.stateObj!, LightColorMode.RGBW) - ) { - this._setRgbWColor( - this._colorBrightnessSliderValue - ? this._adjustColorBrightness( - rgb_color, - (this._colorBrightnessSliderValue * 255) / 100 - ) - : rgb_color - ); - } else if (lightSupportsColorMode(this.stateObj!, LightColorMode.RGB)) { - if (this._brightnessAdjusted) { - const brightnessAdjust = (this._brightnessAdjusted / 255) * 100; - const brightnessPercentage = Math.round( - ((this.stateObj!.attributes.brightness || 0) * brightnessAdjust) / 255 - ); - this.hass.callService("light", "turn_on", { - entity_id: this.stateObj!.entity_id, - brightness_pct: brightnessPercentage, - rgb_color: this._adjustColorBrightness( - rgb_color, - this._brightnessAdjusted, - true - ), - }); - } else { - this.hass.callService("light", "turn_on", { - entity_id: this.stateObj!.entity_id, - rgb_color, - }); - } - } else { - this.hass.callService("light", "turn_on", { - entity_id: this.stateObj!.entity_id, - hs_color: [hs_color[0], hs_color[1] * 100], - }); - } - } - - private _hsColorChanged(ev: CustomEvent) { - if (!ev.detail.value) { - return; - } - this._hsPickerValue = ev.detail.value; - - this._updateColor(); - } - - private _ctColorCursorMoved(ev: CustomEvent) { - const ct = ev.detail.value; - - if (isNaN(ct) || this._ctPickerValue === ct) { - return; - } - - this._ctPickerValue = ct; - - this._throttleUpdateColorTemp(); - } - - private _throttleUpdateColorTemp = throttle(() => { - this.hass.callService("light", "turn_on", { - entity_id: this.stateObj!.entity_id, - color_temp_kelvin: this._ctPickerValue, - }); - }, 500); - - private _ctColorChanged(ev: CustomEvent) { - const ct = ev.detail.value; - - if (isNaN(ct) || this._ctPickerValue === ct) { - return; - } - - this._ctPickerValue = ct; - - this.hass.callService("light", "turn_on", { - entity_id: this.stateObj!.entity_id, - color_temp_kelvin: ct, - }); - } - - private _wvSliderChanged(ev: CustomEvent) { - const target = ev.target as any; - let wv = Number(target.value); - const name = target.name; - - if (isNaN(wv)) { - return; - } - - if (name === "wv") { - this._wvSliderValue = wv; - } else if (name === "cw") { - this._cwSliderValue = wv; - } else if (name === "ww") { - this._wwSliderValue = wv; - } - - wv = Math.min(255, Math.round((wv * 255) / 100)); - - const rgb = getLightCurrentModeRgbColor(this.stateObj!); - - if (name === "wv") { - const rgbw_color = rgb || [0, 0, 0, 0]; - rgbw_color[3] = wv; - this.hass.callService("light", "turn_on", { - entity_id: this.stateObj!.entity_id, - rgbw_color, - }); - return; - } - - const rgbww_color = rgb || [0, 0, 0, 0, 0]; - while (rgbww_color.length < 5) { - rgbww_color.push(0); - } - rgbww_color[name === "cw" ? 3 : 4] = wv; - this.hass.callService("light", "turn_on", { - entity_id: this.stateObj!.entity_id, - rgbww_color, - }); - } - - private _colorBrightnessSliderChanged(ev: CustomEvent) { - const target = ev.target as any; - let value = Number(target.value); - - if (isNaN(value)) { - return; - } - - const oldValue = this._colorBrightnessSliderValue; - this._colorBrightnessSliderValue = value; - - value = (value * 255) / 100; - - const rgb = (getLightCurrentModeRgbColor(this.stateObj!)?.slice(0, 3) || [ - 255, 255, 255, - ]) as [number, number, number]; - - this._setRgbWColor( - this._adjustColorBrightness( - // first normalize the value - oldValue - ? this._adjustColorBrightness(rgb, (oldValue * 255) / 100, true) - : rgb, - value - ) - ); - } - - private _adjustColorBrightness( - rgbColor: [number, number, number], - value?: number, - invert = false - ) { - if (value !== undefined && value !== 255) { - let ratio = value / 255; - if (invert) { - ratio = 1 / ratio; - } - rgbColor[0] = Math.min(255, Math.round(rgbColor[0] * ratio)); - rgbColor[1] = Math.min(255, Math.round(rgbColor[1] * ratio)); - rgbColor[2] = Math.min(255, Math.round(rgbColor[2] * ratio)); - } - return rgbColor; - } - - private _setRgbWColor(rgbColor: [number, number, number]) { - if (lightSupportsColorMode(this.stateObj!, LightColorMode.RGBWW)) { - const rgbww_color: [number, number, number, number, number] = this - .stateObj!.attributes.rgbww_color - ? [...this.stateObj!.attributes.rgbww_color] - : [0, 0, 0, 0, 0]; - this.hass.callService("light", "turn_on", { - entity_id: this.stateObj!.entity_id, - rgbww_color: rgbColor.concat(rgbww_color.slice(3)), - }); - } else if (lightSupportsColorMode(this.stateObj!, LightColorMode.RGBW)) { - const rgbw_color: [number, number, number, number] = this.stateObj! - .attributes.rgbw_color - ? [...this.stateObj!.attributes.rgbw_color] - : [0, 0, 0, 0]; - this.hass.callService("light", "turn_on", { - entity_id: this.stateObj!.entity_id, - rgbw_color: rgbColor.concat(rgbw_color.slice(3)), - }); - } - } - static get styles(): CSSResultGroup { return [ css` :host { + position: relative; display: flex; flex-direction: column; - } - .content { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 24px; flex: 1; } - - ha-hs-color-picker { - max-width: 320px; - min-width: 200px; - margin: 44px 0 44px 0; - } - - ha-temp-color-picker { - max-width: 320px; - min-width: 200px; - margin: 20px 0 44px 0; - } - - ha-labeled-slider { - width: 100%; - } - - .color-temp-value { - font-style: normal; - font-weight: 500; - font-size: 16px; - height: 24px; - line-height: 24px; - letter-spacing: 0.1px; - margin: 0; - direction: ltr; - } - - hr { - border-color: var(--divider-color); - border-bottom: none; - margin: 16px 0; + light-color-picker { + display: flex; + flex-direction: column; + flex: 1; } `, ]; diff --git a/src/dialogs/more-info/components/lights/light-color-picker.ts b/src/dialogs/more-info/components/lights/light-color-picker.ts new file mode 100644 index 0000000000..6d0bf0d3b0 --- /dev/null +++ b/src/dialogs/more-info/components/lights/light-color-picker.ts @@ -0,0 +1,577 @@ +import "@material/mwc-button"; +import "@material/mwc-tab-bar/mwc-tab-bar"; +import "@material/mwc-tab/mwc-tab"; +import { + css, + CSSResultGroup, + html, + LitElement, + nothing, + PropertyValues, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { hs2rgb, rgb2hs } from "../../../../common/color/convert-color"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { throttle } from "../../../../common/util/throttle"; +import "../../../../components/ha-button-toggle-group"; +import "../../../../components/ha-hs-color-picker"; +import "../../../../components/ha-icon-button-prev"; +import "../../../../components/ha-labeled-slider"; +import "../../../../components/ha-temp-color-picker"; +import { + LightColor, + getLightCurrentModeRgbColor, + LightColorMode, + LightEntity, + lightSupportsColor, + lightSupportsColorMode, +} from "../../../../data/light"; +import { HomeAssistant } from "../../../../types"; + +type Mode = "color_temp" | "color"; + +declare global { + interface HASSDomEvents { + "color-changed": LightColor; + } +} + +@customElement("light-color-picker") +class LightColorPicker extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public entityId!: string; + + @state() private _cwSliderValue?: number; + + @state() private _wwSliderValue?: number; + + @state() private _wvSliderValue?: number; + + @state() private _colorBrightnessSliderValue?: number; + + @state() private _brightnessAdjusted?: number; + + @state() private _hsPickerValue?: [number, number]; + + @state() private _ctPickerValue?: number; + + @state() private _mode?: Mode; + + @state() private _modes: Mode[] = []; + + get stateObj() { + return this.hass.states[this.entityId] as LightEntity | undefined; + } + + protected render() { + if (!this.stateObj) { + return nothing; + } + + const supportsRgbww = lightSupportsColorMode( + this.stateObj, + LightColorMode.RGBWW + ); + + const supportsRgbw = + !supportsRgbww && + lightSupportsColorMode(this.stateObj, LightColorMode.RGBW); + + return html` + ${this._modes.length > 1 + ? html` + + ${this._modes.map( + (value) => + html`` + )} + + ` + : nothing} +
+ ${this._mode === LightColorMode.COLOR_TEMP + ? html` +

+ ${this._ctPickerValue ? `${this._ctPickerValue} K` : nothing} +

+ + + ` + : nothing} + ${this._mode === "color" + ? html` + + + + ${supportsRgbw || supportsRgbww + ? html`` + : nothing} + ${supportsRgbw + ? html` + + ` + : nothing} + ${supportsRgbww + ? html` + + + ` + : nothing} + ` + : nothing} +
+ `; + } + + public _updateSliderValues() { + const stateObj = this.stateObj; + + if (stateObj?.state === "on") { + this._brightnessAdjusted = undefined; + if ( + stateObj.attributes.color_mode === LightColorMode.RGB && + stateObj.attributes.rgb_color && + !lightSupportsColorMode(stateObj, LightColorMode.RGBWW) && + !lightSupportsColorMode(stateObj, LightColorMode.RGBW) + ) { + const maxVal = Math.max(...stateObj.attributes.rgb_color); + + if (maxVal < 255) { + this._brightnessAdjusted = maxVal; + } + } + this._ctPickerValue = + stateObj.attributes.color_mode === LightColorMode.COLOR_TEMP + ? stateObj.attributes.color_temp_kelvin + : undefined; + + this._wvSliderValue = + stateObj.attributes.color_mode === LightColorMode.RGBW && + stateObj.attributes.rgbw_color + ? Math.round((stateObj.attributes.rgbw_color[3] * 100) / 255) + : undefined; + this._cwSliderValue = + stateObj.attributes.color_mode === LightColorMode.RGBWW && + stateObj.attributes.rgbww_color + ? Math.round((stateObj.attributes.rgbww_color[3] * 100) / 255) + : undefined; + this._wwSliderValue = + stateObj.attributes.color_mode === LightColorMode.RGBWW && + stateObj.attributes.rgbww_color + ? Math.round((stateObj.attributes.rgbww_color[4] * 100) / 255) + : undefined; + + const currentRgbColor = getLightCurrentModeRgbColor(stateObj); + + this._colorBrightnessSliderValue = currentRgbColor + ? Math.round((Math.max(...currentRgbColor.slice(0, 3)) * 100) / 255) + : undefined; + + this._hsPickerValue = currentRgbColor + ? rgb2hs(currentRgbColor.slice(0, 3) as [number, number, number]) + : undefined; + } else { + this._hsPickerValue = [0, 0]; + this._ctPickerValue = undefined; + this._wvSliderValue = undefined; + this._cwSliderValue = undefined; + this._wwSliderValue = undefined; + } + } + + public willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); + + if (!changedProps.has("entityId") && !changedProps.has("hass")) { + return; + } + + if (changedProps.has("entityId")) { + const supportsTemp = lightSupportsColorMode( + this.stateObj!, + LightColorMode.COLOR_TEMP + ); + + const supportsColor = lightSupportsColor(this.stateObj!); + + const modes: Mode[] = []; + if (supportsColor) { + modes.push("color"); + } + if (supportsTemp) { + modes.push("color_temp"); + } + + this._modes = modes; + this._mode = this.stateObj!.attributes.color_mode + ? this.stateObj!.attributes.color_mode === LightColorMode.COLOR_TEMP + ? LightColorMode.COLOR_TEMP + : "color" + : this._modes[0]; + } + + this._updateSliderValues(); + } + + private _handleTabChanged(ev: CustomEvent): void { + const newMode = this._modes[ev.detail.index]; + if (newMode === this._mode) { + return; + } + this._mode = newMode; + } + + private _hsColorCursorMoved(ev: CustomEvent) { + if (!ev.detail.value) { + return; + } + this._hsPickerValue = ev.detail.value; + + this._throttleUpdateColor(); + } + + private _throttleUpdateColor = throttle(() => this._updateColor(), 500); + + private _updateColor() { + const hs_color = [ + this._hsPickerValue![0], + this._hsPickerValue![1] * 100, + ] as [number, number]; + const rgb_color = hs2rgb(this._hsPickerValue!); + + if ( + lightSupportsColorMode(this.stateObj!, LightColorMode.RGBWW) || + lightSupportsColorMode(this.stateObj!, LightColorMode.RGBW) + ) { + this._setRgbWColor( + this._colorBrightnessSliderValue + ? this._adjustColorBrightness( + rgb_color, + (this._colorBrightnessSliderValue * 255) / 100 + ) + : rgb_color + ); + } else if (lightSupportsColorMode(this.stateObj!, LightColorMode.RGB)) { + if (this._brightnessAdjusted) { + const brightnessAdjust = (this._brightnessAdjusted / 255) * 100; + const brightnessPercentage = Math.round( + ((this.stateObj!.attributes.brightness || 0) * brightnessAdjust) / 255 + ); + const ajustedRgbColor = this._adjustColorBrightness( + rgb_color, + this._brightnessAdjusted, + true + ); + this._applyColor( + { rgb_color: ajustedRgbColor }, + { brightness_pct: brightnessPercentage } + ); + } else { + this._applyColor({ rgb_color }); + } + } else { + this._applyColor({ hs_color }); + } + } + + private _hsColorChanged(ev: CustomEvent) { + if (!ev.detail.value) { + return; + } + this._hsPickerValue = ev.detail.value; + + this._updateColor(); + } + + private _ctColorCursorMoved(ev: CustomEvent) { + const ct = ev.detail.value; + + if (isNaN(ct) || this._ctPickerValue === ct) { + return; + } + + this._ctPickerValue = ct; + + this._throttleUpdateColorTemp(); + } + + private _throttleUpdateColorTemp = throttle(() => { + this._updateColorTemp(); + }, 500); + + private _ctColorChanged(ev: CustomEvent) { + const ct = ev.detail.value; + + if (isNaN(ct) || this._ctPickerValue === ct) { + return; + } + + this._ctPickerValue = ct; + + this._updateColorTemp(); + } + + private _updateColorTemp() { + const color_temp_kelvin = this._ctPickerValue!; + + this._applyColor({ color_temp_kelvin }); + } + + private _wvSliderChanged(ev: CustomEvent) { + const target = ev.target as any; + let wv = Number(target.value); + const name = target.name; + + if (isNaN(wv)) { + return; + } + + if (name === "wv") { + this._wvSliderValue = wv; + } else if (name === "cw") { + this._cwSliderValue = wv; + } else if (name === "ww") { + this._wwSliderValue = wv; + } + + wv = Math.min(255, Math.round((wv * 255) / 100)); + + const rgb = getLightCurrentModeRgbColor(this.stateObj!); + + if (name === "wv") { + const rgbw_color = rgb || [0, 0, 0, 0]; + rgbw_color[3] = wv; + this._applyColor({ + rgbw_color: rgbw_color as [number, number, number, number], + }); + return; + } + + const rgbww_color = rgb || [0, 0, 0, 0, 0]; + while (rgbww_color.length < 5) { + rgbww_color.push(0); + } + rgbww_color[name === "cw" ? 3 : 4] = wv; + this._applyColor({ + rgbww_color: rgbww_color as [number, number, number, number, number], + }); + } + + private _applyColor(color: LightColor, params?: Record) { + fireEvent(this, "color-changed", color); + this.hass.callService("light", "turn_on", { + entity_id: this.stateObj!.entity_id, + ...color, + ...params, + }); + } + + private _colorBrightnessSliderChanged(ev: CustomEvent) { + const target = ev.target as any; + let value = Number(target.value); + + if (isNaN(value)) { + return; + } + + const oldValue = this._colorBrightnessSliderValue; + this._colorBrightnessSliderValue = value; + + value = (value * 255) / 100; + + const rgb = (getLightCurrentModeRgbColor(this.stateObj!)?.slice(0, 3) || [ + 255, 255, 255, + ]) as [number, number, number]; + + this._setRgbWColor( + this._adjustColorBrightness( + // first normalize the value + oldValue + ? this._adjustColorBrightness(rgb, (oldValue * 255) / 100, true) + : rgb, + value + ) + ); + } + + private _adjustColorBrightness( + rgbColor: [number, number, number], + value?: number, + invert = false + ) { + const isBlack = rgbColor.every((c) => c === 0); + if (isBlack) { + rgbColor[0] = 255; + rgbColor[1] = 255; + rgbColor[2] = 255; + } + if (value !== undefined && value !== 255) { + let ratio = value / 255; + if (invert) { + ratio = 1 / ratio; + } + rgbColor[0] = Math.min(255, Math.round(rgbColor[0] * ratio)); + rgbColor[1] = Math.min(255, Math.round(rgbColor[1] * ratio)); + rgbColor[2] = Math.min(255, Math.round(rgbColor[2] * ratio)); + } + return rgbColor; + } + + private _setRgbWColor(rgbColor: [number, number, number]) { + if (lightSupportsColorMode(this.stateObj!, LightColorMode.RGBWW)) { + const rgbwwColor: [number, number, number, number, number] = this + .stateObj!.attributes.rgbww_color + ? [...this.stateObj!.attributes.rgbww_color] + : [0, 0, 0, 0, 0]; + const rgbww_color = rgbColor.concat(rgbwwColor.slice(3)) as [ + number, + number, + number, + number, + number + ]; + this._applyColor({ rgbww_color }); + } else if (lightSupportsColorMode(this.stateObj!, LightColorMode.RGBW)) { + const rgbwColor: [number, number, number, number] = this.stateObj! + .attributes.rgbw_color + ? [...this.stateObj!.attributes.rgbw_color] + : [0, 0, 0, 0]; + const rgbw_color = rgbColor.concat(rgbwColor.slice(3)) as [ + number, + number, + number, + number + ]; + this._applyColor({ rgbw_color }); + } + } + + static get styles(): CSSResultGroup { + return [ + css` + :host { + display: flex; + flex-direction: column; + } + .content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + flex: 1; + } + + ha-hs-color-picker { + max-width: 300px; + min-width: 200px; + margin: 44px 0 44px 0; + } + + ha-temp-color-picker { + max-width: 300px; + min-width: 200px; + margin: 20px 0 44px 0; + } + + ha-labeled-slider { + width: 100%; + } + + .color-temp-value { + font-style: normal; + font-weight: 500; + font-size: 16px; + height: 24px; + line-height: 24px; + letter-spacing: 0.1px; + margin: 0; + direction: ltr; + } + + hr { + border-color: var(--divider-color); + border-bottom: none; + margin: 16px 0; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "light-color-picker": LightColorPicker; + } +} diff --git a/src/dialogs/more-info/components/lights/show-dialog-light-color-favorite.ts b/src/dialogs/more-info/components/lights/show-dialog-light-color-favorite.ts new file mode 100644 index 0000000000..ed6e36cbc8 --- /dev/null +++ b/src/dialogs/more-info/components/lights/show-dialog-light-color-favorite.ts @@ -0,0 +1,41 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; +import { ExtEntityRegistryEntry } from "../../../../data/entity_registry"; +import { LightColor } from "../../../../data/light"; + +export interface LightColorFavoriteDialogParams { + entry: ExtEntityRegistryEntry; + submit?: (color?: LightColor) => void; + cancel?: () => void; +} + +export const loadLightColorFavoriteDialog = () => + import("./dialog-light-color-favorite"); + +export const showLightColorFavoriteDialog = ( + element: HTMLElement, + dialogParams: LightColorFavoriteDialogParams +) => + new Promise((resolve) => { + const origCancel = dialogParams.cancel; + const origSubmit = dialogParams.submit; + + fireEvent(element, "show-dialog", { + dialogTag: "dialog-light-color-favorite", + dialogImport: loadLightColorFavoriteDialog, + dialogParams: { + ...dialogParams, + cancel: () => { + resolve(null); + if (origCancel) { + origCancel(); + } + }, + submit: (color: LightColor) => { + resolve(color); + if (origSubmit) { + origSubmit(color); + } + }, + }, + }); + }); diff --git a/src/dialogs/more-info/controls/more-info-light.ts b/src/dialogs/more-info/controls/more-info-light.ts index b1d013bb80..b212498bf7 100644 --- a/src/dialogs/more-info/controls/more-info-light.ts +++ b/src/dialogs/more-info/controls/more-info-light.ts @@ -16,6 +16,7 @@ import { } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; +import { fireEvent } from "../../../common/dom/fire_event"; import { stopPropagation } from "../../../common/dom/stop_propagation"; import { computeAttributeNameDisplay } from "../../../common/entity/compute_attribute_display"; import { supportsFeature } from "../../../common/entity/supports-feature"; @@ -25,9 +26,15 @@ import "../../../components/ha-button-menu"; import "../../../components/ha-outlined-button"; import "../../../components/ha-outlined-icon-button"; import "../../../components/ha-select"; -import { UNAVAILABLE } from "../../../data/entity"; +import { ON, UNAVAILABLE } from "../../../data/entity"; +import { + ExtEntityRegistryEntry, + updateEntityRegistryEntry, +} from "../../../data/entity_registry"; import { forwardHaptic } from "../../../data/haptics"; import { + computeDefaultFavoriteColors, + LightColor, LightColorMode, LightEntity, LightEntityFeature, @@ -39,7 +46,9 @@ import type { HomeAssistant } from "../../../types"; import { moreInfoControlStyle } from "../components/ha-more-info-control-style"; import "../components/ha-more-info-state-header"; import "../components/ha-more-info-toggle"; +import "../components/lights/ha-favorite-color-button"; import "../components/lights/ha-more-info-light-brightness"; +import { showLightColorFavoriteDialog } from "../components/lights/show-dialog-light-color-favorite"; import { showLightColorPickerView } from "../components/lights/show-view-light-color-picker"; @customElement("more-info-light") @@ -48,25 +57,45 @@ class MoreInfoLight extends LitElement { @property({ attribute: false }) public stateObj?: LightEntity; + @property({ attribute: false }) public entry?: ExtEntityRegistryEntry | null; + @state() private _effect?: string; @state() private _selectedBrightness?: number; + @state() private _focusedFavoriteIndex?: number; + private _brightnessChanged(ev) { const value = (ev.detail as any).value; if (isNaN(value)) return; this._selectedBrightness = value; } - protected updated(changedProps: PropertyValues): void { + protected updated(changedProps: PropertyValues): void { if (changedProps.has("stateObj")) { this._selectedBrightness = this.stateObj?.attributes.brightness ? Math.round((this.stateObj.attributes.brightness * 100) / 255) : undefined; this._effect = this.stateObj?.attributes.effect; + + if (this.stateObj?.state !== ON) { + this._focusedFavoriteIndex = undefined; + } } } + private get _favoriteColors(): LightColor[] { + if (this.entry) { + if (this.entry.options?.light?.favorites_colors) { + return this.entry.options.light.favorites_colors; + } + if (this.stateObj) { + return computeDefaultFavoriteColors(this.stateObj); + } + } + return []; + } + protected render() { if (!this.hass || !this.stateObj) { return nothing; @@ -79,13 +108,13 @@ class MoreInfoLight extends LitElement { const supportsColor = lightSupportsColor(this.stateObj); + const supportsBrightness = lightSupportsBrightness(this.stateObj); + const supportsWhite = lightSupportsColorMode( this.stateObj, LightColorMode.WHITE ); - const supportsBrightness = lightSupportsBrightness(this.stateObj); - const supportsEffects = supportsFeature( this.stateObj, LightEntityFeature.EFFECT @@ -121,63 +150,76 @@ class MoreInfoLight extends LitElement { .iconPathOff=${mdiLightbulbOff} > `} - ${supportsColorTemp || - supportsColor || - supportsEffects || - supportsBrightness + ${supportsColorTemp || supportsColor || supportsBrightness ? html`
${supportsBrightness - ? html` - - - - ` + ? html` + + ` : nothing} - ${supportsColorTemp || supportsColor - ? html` - - - ` + ${supportsColor || supportsColorTemp + ? html` + ` : nothing} ${supportsWhite ? html` ` : nothing} + ${this._favoriteColors.map((color, index) => { + const editMode = this._focusedFavoriteIndex === index; + return html` + + + `; + })}
` : nothing} @@ -225,6 +267,10 @@ class MoreInfoLight extends LitElement { `; } + private _removeFocus = () => { + this._focusedFavoriteIndex = undefined; + }; + private _toggle = () => { const service = this.stateObj?.state === "on" ? "turn_off" : "turn_on"; forwardHaptic("light"); @@ -245,13 +291,64 @@ class MoreInfoLight extends LitElement { ); }; - private _setWhiteColor = () => { + private _editFavoriteColor = async (index) => { + // Make sure the current favorite color is set + this._applyFavoriteColor(index); + const color = await showLightColorFavoriteDialog(this, { + entry: this.entry!, + }); + + if (color) { + const newFavoriteColors = [...this._favoriteColors]; + + newFavoriteColors[index] = color; + + const result = await updateEntityRegistryEntry( + this.hass, + this.entry!.entity_id, + { + options_domain: "light", + options: { + favorites_colors: newFavoriteColors, + }, + } + ); + + fireEvent(this, "entity-entry-updated", result.entity_entry); + } else { + this._applyFavoriteColor(index); + } + this._focusedFavoriteIndex = index; + }; + + private _applyFavoriteColor = (index: number) => { + const favorite = this._favoriteColors[index]; + this.hass.callService("light", "turn_on", { + entity_id: this.stateObj!.entity_id, + ...favorite, + }); + }; + + private _setWhite = () => { this.hass.callService("light", "turn_on", { entity_id: this.stateObj!.entity_id, white: true, }); }; + private _handleFavoriteButton = (ev) => { + ev.stopPropagation(); + const index = ev.target.index; + if (this._focusedFavoriteIndex === index) { + this._editFavoriteColor(index); + return; + } + if (this.hass.user?.is_admin) { + this._focusedFavoriteIndex = index; + } + this._applyFavoriteColor(index); + }; + private _handleEffectButton(ev) { ev.stopPropagation(); ev.preventDefault(); @@ -272,21 +369,26 @@ class MoreInfoLight extends LitElement { return [ moreInfoControlStyle, css` + .buttons { + flex-wrap: wrap; + max-width: 250px; + } + .color-rgb-mode, + .color-temp-mode { + border-radius: 9999px; + --md-sys-color-outline: var(--divider-color); + } .color-rgb-mode { background-image: url("/static/images/color_wheel.png"); background-size: cover; - border-radius: 20px; - --md-sys-color-outline: var(--divider-color); } .color-temp-mode { background: linear-gradient( 0, - rgb(255, 160, 0) 0%, + rgb(166, 209, 255) 0%, white 50%, - rgb(166, 209, 255) 100% + rgb(255, 160, 0) 100% ); - border-radius: 20px; - --md-sys-color-outline: var(--divider-color); } .color-rgb-mode[disabled], .color-temp-mode[disabled] { diff --git a/src/dialogs/more-info/ha-more-info-dialog.ts b/src/dialogs/more-info/ha-more-info-dialog.ts index c66b847a96..201714098c 100644 --- a/src/dialogs/more-info/ha-more-info-dialog.ts +++ b/src/dialogs/more-info/ha-more-info-dialog.ts @@ -241,7 +241,13 @@ export class MoreInfoDialog extends LitElement { const isInfoView = this._currView === "info" && !this._childView; return html` - + ${isInfoView ? html` @@ -377,6 +383,7 @@ export class MoreInfoDialog extends LitElement { dialogInitialFocus .hass=${this.hass} .entityId=${this._entityId} + .entry=${this._entry} > ` : this._currView === "history" @@ -445,7 +452,16 @@ export class MoreInfoDialog extends LitElement { } .content { + display: flex; + flex-direction: column; outline: none; + flex: 1; + } + + .child-view { + display: flex; + flex-direction: column; + flex: 1; } ha-related-items, @@ -454,12 +470,6 @@ export class MoreInfoDialog extends LitElement { display: block; } - @media all and (max-width: 450px) { - .child-view > * { - min-height: calc(100vh - var(--header-height)); - } - } - @media all and (max-width: 450px), all and (max-height: 500px) { /* When in fullscreen dialog should be attached to top */ ha-dialog { @@ -469,7 +479,7 @@ export class MoreInfoDialog extends LitElement { @media all and (min-width: 600px) and (min-height: 501px) { ha-dialog { - --mdc-dialog-min-width: 560px; + --mdc-dialog-min-width: 580px; --mdc-dialog-max-width: 580px; --mdc-dialog-max-height: calc(100% - 72px); } diff --git a/src/dialogs/more-info/ha-more-info-info.ts b/src/dialogs/more-info/ha-more-info-info.ts index f789ad64a7..c690c654a2 100644 --- a/src/dialogs/more-info/ha-more-info-info.ts +++ b/src/dialogs/more-info/ha-more-info-info.ts @@ -1,7 +1,8 @@ import { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement } from "lit"; +import { css, html, LitElement, nothing } from "lit"; import { customElement, property } from "lit/decorators"; import { computeDomain } from "../../common/entity/compute_domain"; +import { ExtEntityRegistryEntry } from "../../data/entity_registry"; import type { HomeAssistant } from "../../types"; import { computeShowHistoryComponent, @@ -20,6 +21,8 @@ export class MoreInfoInfo extends LitElement { @property() public entityId!: string; + @property() public entry?: ExtEntityRegistryEntry | null; + protected render() { const entityId = this.entityId; const stateObj = this.hass.states[entityId] as HassEntity | undefined; @@ -35,7 +38,7 @@ export class MoreInfoInfo extends LitElement { "ui.dialogs.entity_registry.editor.unavailable" )} ` - : ""} + : nothing} ${stateObj?.attributes.restored && entityRegObj ? html` ${this.hass.localize( @@ -45,7 +48,7 @@ export class MoreInfoInfo extends LitElement { } )} ` - : ""} + : nothing}
${DOMAINS_NO_INFO.includes(domain) || isNewMoreInfo ? "" @@ -74,8 +77,8 @@ export class MoreInfoInfo extends LitElement { ?full-height=${isNewMoreInfo} .stateObj=${stateObj} .hass=${this.hass} + .entry=${this.entry} > -
`; @@ -83,15 +86,15 @@ export class MoreInfoInfo extends LitElement { static get styles() { return css` + :host { + display: flex; + flex-direction: column; + flex: 1; + } .container { display: flex; flex-direction: column; - } - - @media all and (max-width: 450px) { - .container { - min-height: calc(100vh - var(--header-height)); - } + flex: 1; } .content { diff --git a/src/dialogs/more-info/more-info-content.ts b/src/dialogs/more-info/more-info-content.ts index 2185206433..2c72f5a6b1 100644 --- a/src/dialogs/more-info/more-info-content.ts +++ b/src/dialogs/more-info/more-info-content.ts @@ -2,6 +2,7 @@ import { HassEntity } from "home-assistant-js-websocket"; import { PropertyValues, ReactiveElement } from "lit"; import { property } from "lit/decorators"; import dynamicContentUpdater from "../../common/dom/dynamic_content_updater"; +import { ExtEntityRegistryEntry } from "../../data/entity_registry"; import { importMoreInfoControl } from "../../panels/lovelace/custom-card-helpers"; import { HomeAssistant } from "../../types"; import { stateMoreInfoType } from "./state_more_info_control"; @@ -11,6 +12,8 @@ class MoreInfoContent extends ReactiveElement { @property({ attribute: false }) public stateObj?: HassEntity; + @property({ attribute: false }) public entry?: ExtEntityRegistryEntry | null; + private _detachedChild?: ChildNode; protected createRenderRoot() { @@ -21,6 +24,7 @@ class MoreInfoContent extends ReactiveElement { protected update(changedProps: PropertyValues): void { super.update(changedProps); const stateObj = this.stateObj; + const entry = this.entry; const hass = this.hass; if (!stateObj || !hass) { @@ -54,6 +58,7 @@ class MoreInfoContent extends ReactiveElement { dynamicContentUpdater(this, moreInfoType.toUpperCase(), { hass, stateObj, + entry, }); } } diff --git a/src/resources/styles.ts b/src/resources/styles.ts index 7c03bf6b76..ccec54cc01 100644 --- a/src/resources/styles.ts +++ b/src/resources/styles.ts @@ -321,7 +321,7 @@ export const haStyleDialog = css` --mdc-dialog-min-height: 100%; --mdc-dialog-max-height: 100%; --vertical-align-dialog: flex-end; - --ha-dialog-border-radius: 0px; + --ha-dialog-border-radius: 0; } } mwc-button.warning, diff --git a/src/translations/en.json b/src/translations/en.json index c9dcbc83cb..a351450608 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -936,6 +936,10 @@ "color": "Color", "color_temp": "Temperature" } + }, + "favorite_color": { + "set": "Set favorite color {number}", + "edit": "Edit favorite color {number}" } }, "fan": { @@ -1331,6 +1335,9 @@ "title": "Enter code", "input_label": "Code" }, + "light-color-favorite": { + "title": "Edit favorite color" + }, "tts-try": { "header": "Try text-to-speech", "message": "Message", diff --git a/yarn.lock b/yarn.lock index 715fcc6327..de488acc02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3077,14 +3077,14 @@ __metadata: languageName: node linkType: hard -"@material/web@npm:=1.0.0-pre.8": - version: 1.0.0-pre.8 - resolution: "@material/web@npm:1.0.0-pre.8" +"@material/web@npm:=1.0.0-pre.9": + version: 1.0.0-pre.9 + resolution: "@material/web@npm:1.0.0-pre.9" dependencies: - lit: ^2.3.0 + lit: ^2.7.4 safevalues: ^0.4.3 tslib: ^2.4.0 - checksum: 3470cbc7ec69f8215cd0a70c91df884c94e7a628b396f1ec5f2c4cac836ef17355424df78444fb55f9b3c45772e5649a4258217f11c23db6ac45d37a4acdb36b + checksum: c4c6a5328448ee55a6f68260bbadecda2cabd031bf81502934fff95422e63b6903f9737e35ae3cd7e87f6f146a13dc3f492e92fc4e3cef3caee8d0fd4e177a7a languageName: node linkType: hard @@ -9600,7 +9600,7 @@ __metadata: "@material/mwc-top-app-bar": 0.27.0 "@material/mwc-top-app-bar-fixed": 0.27.0 "@material/top-app-bar": =14.0.0-canary.53b3cad2f.0 - "@material/web": =1.0.0-pre.8 + "@material/web": =1.0.0-pre.9 "@mdi/js": 7.2.96 "@mdi/svg": 7.2.96 "@octokit/auth-oauth-device": 4.0.4 @@ -11409,7 +11409,7 @@ __metadata: languageName: node linkType: hard -"lit@npm:2.7.4, lit@npm:^2.0.0, lit@npm:^2.0.0-rc.2, lit@npm:^2.2.1, lit@npm:^2.3.0, lit@npm:^2.7.0, lit@npm:^2.7.2": +"lit@npm:2.7.4, lit@npm:^2.0.0, lit@npm:^2.0.0-rc.2, lit@npm:^2.2.1, lit@npm:^2.7.0, lit@npm:^2.7.2, lit@npm:^2.7.4": version: 2.7.4 resolution: "lit@npm:2.7.4" dependencies: