diff --git a/locales/en-US/browser/browser/zen-boosts.ftl b/locales/en-US/browser/browser/zen-boosts.ftl index 65f67d689..b14615d19 100644 --- a/locales/en-US/browser/browser/zen-boosts.ftl +++ b/locales/en-US/browser/browser/zen-boosts.ftl @@ -11,6 +11,7 @@ zen-boost-edit-reset = zen-boost-edit-delete = .label = Delete Boost zen-boost-size = Size +zen-boost-case = Case zen-boost-zap = Zap zen-boost-code = Code zen-boost-back = Back diff --git a/src/browser/themes/shared/zen-icons/icons.css b/src/browser/themes/shared/zen-icons/icons.css index 0e949942f..1f7779e35 100644 --- a/src/browser/themes/shared/zen-icons/icons.css +++ b/src/browser/themes/shared/zen-icons/icons.css @@ -1005,15 +1005,19 @@ fill-opacity: 0.65; } -#zen-boost-text-case-toggle { +#zen-boost-case[case-mode="none"] { + list-style-image: none; +} + +#zen-boost-case[case-mode="capitalize"] { list-style-image: url("text-title-case.svg"); } -#zen-boost-text-case-toggle[case-mode="uppercase"] { +#zen-boost-case[case-mode="uppercase"] { list-style-image: url("text-uppercase.svg"); } -#zen-boost-text-case-toggle[case-mode="lowercase"] { +#zen-boost-case[case-mode="lowercase"] { list-style-image: url("text-lowercase.svg"); } diff --git a/src/docshell/base/BrowsingContext-h.patch b/src/docshell/base/BrowsingContext-h.patch index 6590768cd..d46eee4d5 100644 --- a/src/docshell/base/BrowsingContext-h.patch +++ b/src/docshell/base/BrowsingContext-h.patch @@ -1,30 +1,35 @@ diff --git a/docshell/base/BrowsingContext.h b/docshell/base/BrowsingContext.h -index 4e20d7b602932621baf9082f6d28911701b7aa5b..8a59b81f0c82c94bac9d5c536a4b12f69f260dcf 100644 +index 4e20d7b602932621baf9082f6d28911701b7aa5b..d6e141b235ce60be5db86bc40578d0741b79b014 100644 --- a/docshell/base/BrowsingContext.h +++ b/docshell/base/BrowsingContext.h -@@ -265,6 +265,8 @@ struct EmbedderColorSchemes { +@@ -265,6 +265,9 @@ struct EmbedderColorSchemes { FIELD(HistoryEntryCount, uint32_t) \ FIELD(HasRestoreData, bool) \ FIELD(SessionStoreEpoch, uint32_t) \ + FIELD(ZenBoostsData, nscolor) \ ++ FIELD(ZenBoostsComplementaryRotation, float) \ + FIELD(IsZenBoostsInverted, bool) \ /* Whether we can execute scripts in this BrowsingContext. Has no effect \ * unless scripts are also allowed in the parent WindowContext. */ \ FIELD(AllowJavascript, bool) \ -@@ -680,6 +682,8 @@ class BrowsingContext : public nsILoadContext, public nsWrapperCache { +@@ -680,6 +683,11 @@ class BrowsingContext : public nsILoadContext, public nsWrapperCache { bool FullscreenAllowed() const; + auto ZenBoostsData() const { return GetZenBoostsData(); } ++ auto ZenBoostsComplementaryRotation() const { ++ return GetZenBoostsComplementaryRotation(); ++ } + auto IsZenBoostsInverted() const { return GetIsZenBoostsInverted(); } float FullZoom() const { return GetFullZoom(); } float TextZoom() const { return GetTextZoom(); } -@@ -1284,6 +1288,8 @@ class BrowsingContext : public nsILoadContext, public nsWrapperCache { +@@ -1284,6 +1292,9 @@ class BrowsingContext : public nsILoadContext, public nsWrapperCache { } void DidSet(FieldIndex, uint32_t aOldValue); + void DidSet(FieldIndex, nscolor aOldValue); ++ void DidSet(FieldIndex, float aOldValue); + void DidSet(FieldIndex, bool aOldValue); using CanSetResult = syncedcontext::CanSetResult; diff --git a/src/dom/chrome-webidl/BrowsingContext-webidl.patch b/src/dom/chrome-webidl/BrowsingContext-webidl.patch index cae81472a..fa4f77e9d 100644 --- a/src/dom/chrome-webidl/BrowsingContext-webidl.patch +++ b/src/dom/chrome-webidl/BrowsingContext-webidl.patch @@ -1,12 +1,13 @@ diff --git a/dom/chrome-webidl/BrowsingContext.webidl b/dom/chrome-webidl/BrowsingContext.webidl -index 43f28a6aad9d0dd3e3c908e8472244fc6aa32a74..60bfd83a9da2923ab3811f363320db474323e636 100644 +index 43f28a6aad9d0dd3e3c908e8472244fc6aa32a74..a6d47522f89589c1e69a8e7570c17b7e05330bb6 100644 --- a/dom/chrome-webidl/BrowsingContext.webidl +++ b/dom/chrome-webidl/BrowsingContext.webidl -@@ -179,6 +179,9 @@ interface BrowsingContext { +@@ -179,6 +179,10 @@ interface BrowsingContext { [SetterThrows] attribute float textZoom; + [SetterThrows] attribute long zenBoostsData; ++ [SetterThrows] attribute float zenBoostsComplementaryRotation; + [SetterThrows] attribute boolean isZenBoostsInverted; + // Override the dots-per-CSS-pixel scaling factor in this BrowsingContext diff --git a/src/zen/boosts/ZenBoostsEditor.mjs b/src/zen/boosts/ZenBoostsEditor.mjs index d005125f0..125726744 100644 --- a/src/zen/boosts/ZenBoostsEditor.mjs +++ b/src/zen/boosts/ZenBoostsEditor.mjs @@ -39,6 +39,7 @@ export class nsZenBoostEditor { this.isMouseDown = false; this.wasDragging = false; + this.dragTarget = ""; this.mouseDownPosition = { x: 0, y: 0 }; this.lastDotSetPos = { x: 0, y: 0 }; this.currentBoostData = null; @@ -79,8 +80,11 @@ export class nsZenBoostEditor { .addEventListener("input", this.onColorOptionChange.bind(this)); this.doc - .getElementById("zen-boost-text-case-toggle") + .getElementById("zen-boost-case") .addEventListener("click", this.onBoostCasePressed.bind(this)); + this.doc + .getElementById("zen-boost-size") + .addEventListener("click", this.onBoostSizePressed.bind(this)); this.doc .getElementById("zen-boost-zap") .addEventListener("click", this.onZapButtonPressed.bind(this)); @@ -277,6 +281,11 @@ export class nsZenBoostEditor { "Impact", "Palatino Linotype", "Tahoma", + "Helvetica", + "Garamond", + "Century Gothic", + "Arial Black", + "Papyrus", ]; return cFonts; } @@ -291,7 +300,7 @@ export class nsZenBoostEditor { const fontButtonGroup = this.doc.getElementById("zen-boost-font-grid"); const fontList = this.doc.getElementById("zen-boost-font-select"); - const buttonCount = 10; + const buttonCount = 15; for (let i = 0; i < Math.min(commonFonts.length, buttonCount); i++) { let font = fonts[i]; // Fallback @@ -561,7 +570,9 @@ ${cssSelector} { this.wasDragging = true; event.preventDefault(); - if (event.target.id != "zen-boost-magic-theme") { + if (this.dragTarget == "zen-boost-color-picker-dot-secondary") { + this.setSecondaryDotPos(event.clientX, event.clientY); + } else if (event.target.id != "zen-boost-magic-theme") { this.setDotPos(event.clientX, event.clientY, false); } } @@ -579,6 +590,7 @@ ${cssSelector} { this.mouseDownPosition = { x: event.clientX, y: event.clientY }; this.isMouseDown = true; + this.dragTarget = event.target.id; } /** @@ -600,18 +612,40 @@ ${cssSelector} { * (none, lower, upper) and updating the UI accordingly. */ onBoostCasePressed() { - if (this.currentBoostData.textCaseOverride == "lowercase") { - this.currentBoostData.textCaseOverride = "uppercase"; - } else if (this.currentBoostData.textCaseOverride == "uppercase") { + if (this.currentBoostData.textCaseOverride == "uppercase") { + this.currentBoostData.textCaseOverride = "lowercase"; + } else if (this.currentBoostData.textCaseOverride == "lowercase") { + this.currentBoostData.textCaseOverride = "capitalize"; + } else if (this.currentBoostData.textCaseOverride == "capitalize") { this.currentBoostData.textCaseOverride = "none"; } else { - this.currentBoostData.textCaseOverride = "lowercase"; + this.currentBoostData.textCaseOverride = "uppercase"; } this.updateCaseButtonVisuals(); this.updateCurrentBoost(); } + /** + * Handles the size toggle button press, cycling through size override options + */ + onBoostSizePressed() { + if (this.currentBoostData.sizeOverride == 1) { + this.currentBoostData.sizeOverride = 1.1; + } else if (this.currentBoostData.sizeOverride == 1.1) { + this.currentBoostData.sizeOverride = 1.25; + } else if (this.currentBoostData.sizeOverride == 1.25) { + this.currentBoostData.sizeOverride = 1.5; + } else if (this.currentBoostData.sizeOverride == 1.5) { + this.currentBoostData.sizeOverride = 0.9; + } else if (this.currentBoostData.sizeOverride == 0.9) { + this.currentBoostData.sizeOverride = 1; + } + + this.updateSizeButtonVisuals(); + this.updateCurrentBoost(); + } + /** * Handles changes to color option sliders (contrast, brightness, saturation) * and updates the current boost data accordingly. @@ -649,6 +683,13 @@ ${cssSelector} { this.setDotPos(null, null); } + /** + * Resets the secondary color picker dot to the center position (default state). + */ + resetSecondaryDotPosition() { + this.setSecondaryDotPos(null, null); + } + /** * Handles clicks on the theme picker gradient or magic theme button. * Updates the dot position or toggles auto-theme mode based on the click target. @@ -665,7 +706,7 @@ ${cssSelector} { this.currentBoostData.autoTheme = !this.currentBoostData.autoTheme; this.updateButtonToggleVisuals(); this.updateCurrentBoost(); - } else { + } else if (this.dragTarget != "zen-boost-color-picker-dot-secondary") { this.setDotPos(event.clientX, event.clientY, !this.wasDragging); } this.wasDragging = false; @@ -681,7 +722,10 @@ ${cssSelector} { */ setDotPos(pixelX, pixelY, animate = true) { const gradient = this.doc.querySelector(".zen-boost-color-picker-gradient"); - const dot = this.doc.querySelector(".zen-boost-color-picker-dot"); + const dot = this.doc.querySelector("#zen-boost-color-picker-dot-primary"); + const dotSec = this.doc.querySelector( + "#zen-boost-color-picker-dot-secondary" + ); const rect = gradient.getBoundingClientRect(); const padding = 50; @@ -690,6 +734,9 @@ ${cssSelector} { const centerY = rect.top + rect.height / 2; const radius = (rect.width - padding) / 2; + let pixelXSec = pixelX; + let pixelYSec = pixelY; + if (!animate) { let nDistance = Math.sqrt( (pixelX - this.lastDotSetPos.x) ** 2 + @@ -710,6 +757,8 @@ ${cssSelector} { if (pixelX == null || pixelY == null) { pixelX = centerX; pixelY = centerY; + pixelXSec = centerX; + pixelYSec = centerY; this.currentBoostData.dotAngleDeg = 0; this.currentBoostData.dotDistance = 0; @@ -719,8 +768,9 @@ ${cssSelector} { ); distance = Math.min(distance, radius); // Clamp distance - const angle = Math.atan2(pixelY - centerY, pixelX - centerX); + // Primary dot + const angle = Math.atan2(pixelY - centerY, pixelX - centerX); pixelX = centerX + Math.cos(angle) * distance; pixelY = centerY + Math.sin(angle) * distance; @@ -736,6 +786,15 @@ ${cssSelector} { // Map to 0-1 range this.currentBoostData.dotDistance = distance / radius; + // Secondary dot + + const angleSec = + (angle + + (this.currentBoostData.secondaryDotAngleDegDelta * Math.PI) / 180) % + (Math.PI * 2); + pixelXSec = centerX + Math.cos(angleSec) * distance; + pixelYSec = centerY + Math.sin(angleSec) * distance; + // Enable color boosting again if (!this.currentBoostData.enableColorBoost) { this.onToggleDisable(false); @@ -745,18 +804,26 @@ ${cssSelector} { const relativeX = pixelX - rect.left; const relativeY = pixelY - rect.top; + const relativeXSec = pixelXSec - rect.left; + const relativeYSec = pixelYSec - rect.top; // Capture normalized position of dot for restoring it correctly later this.currentBoostData.dotPos.x = relativeX / rect.width; this.currentBoostData.dotPos.y = relativeY / rect.height; + this.currentBoostData.secondaryDotPos ||= {}; + this.currentBoostData.secondaryDotPos.x = relativeXSec / rect.width; + this.currentBoostData.secondaryDotPos.y = relativeYSec / rect.height; dot.setAttribute("animated", animate ? "true" : "false"); dot.style.left = `${relativeX}px`; dot.style.top = `${relativeY}px`; + dotSec.setAttribute("animated", animate ? "true" : "false"); + dotSec.style.left = `${relativeXSec}px`; + dotSec.style.top = `${relativeYSec}px`; this.updateButtonToggleVisuals(); this.updateDot(); - this.updateCircleRadius(animate); + this.updateCircleRadius(); this.updateCurrentBoost(); } @@ -765,29 +832,209 @@ ${cssSelector} { * based on the current boost data's angle and distance values. */ updateDot() { - const dot = this.doc.querySelector(".zen-boost-color-picker-dot"); + const dot = this.doc.querySelector("#zen-boost-color-picker-dot-primary"); + const dotSec = this.doc.querySelector( + "#zen-boost-color-picker-dot-secondary" + ); dot.style.setProperty( "--zen-theme-picker-dot-color", `hsl(${this.currentBoostData.dotAngleDeg}deg, ${this.currentBoostData.dotDistance * 100}%, 55%)` ); + dotSec.style.setProperty( + "--zen-theme-picker-dot-color", + `hsl(${this.currentBoostData.dotAngleDeg + this.currentBoostData.secondaryDotAngleDegDelta}deg, ${this.currentBoostData.dotDistance * 100}%, 20%)` + ); + } + + /** + * Sets the position of the secondary color picker dot on the gradient and updates + * the boost data with the corresponding angle values. + * + * @param {number|null} pixelX - The X coordinate in pixels. + * @param {number|null} pixelY - The Y coordinate in pixels. + */ + setSecondaryDotPos(pixelX, pixelY) { + const gradient = this.doc.querySelector(".zen-boost-color-picker-gradient"); + const dotSec = this.doc.querySelector( + "#zen-boost-color-picker-dot-secondary" + ); + + const rect = gradient.getBoundingClientRect(); + const padding = 50; + + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const radius = (rect.width - padding) / 2; + + let angle = null; + if (!pixelX || !pixelY) { + pixelX = centerX; + pixelY = centerY; + angle = 32; // Default angle + } else { + angle = Math.atan2(pixelY - centerY, pixelX - centerX); + pixelX = + centerX + Math.cos(angle) * this.currentBoostData.dotDistance * radius; + pixelY = + centerY + Math.sin(angle) * this.currentBoostData.dotDistance * radius; + } + + // Rad to degree + this.currentBoostData.secondaryDotAngleDegDelta = + ((angle * 180) / Math.PI + 100 - this.currentBoostData.dotAngleDeg) % 360; + if (this.currentBoostData.secondaryDotAngleDegDelta < 0) { + this.currentBoostData.secondaryDotAngleDegDelta += 360; + } + + const relativeX = pixelX - rect.left; + const relativeY = pixelY - rect.top; + + // Capture normalized position of dot for restoring it correctly later + this.currentBoostData.secondaryDotPos.x = relativeX / rect.width; + this.currentBoostData.secondaryDotPos.y = relativeY / rect.height; + + dotSec.setAttribute("animated", "false"); + dotSec.style.left = `${relativeX}px`; + dotSec.style.top = `${relativeY}px`; + + this.updateButtonToggleVisuals(); + this.updateDot(); + this.updateCircleRadius(); + this.updateCurrentBoost(); } /** * Updates the radius of the circle based on the dot's position. - * - * @param {boolean} animate - Whether to animate the radius change (default: true). */ - updateCircleRadius(animate = true) { + updateCircleRadius() { const gradient = this.doc.querySelector(".zen-boost-color-picker-gradient"); const rect = gradient.getBoundingClientRect(); const padding = 50; const radius = (rect.width - padding) / 2; + const cx = rect.width / 2; + const cy = rect.height / 2; // Updating the circle size to match the distance of the point const circle = this.doc.querySelector(".zen-boost-color-picker-circle"); - circle.setAttribute("animated", animate ? "true" : "false"); + circle.setAttribute("animated", "false"); circle.style.width = `${this.currentBoostData.dotDistance * radius * 2}px`; circle.style.height = `${this.currentBoostData.dotDistance * radius * 2}px`; + + const dotColor = `hsl(${this.currentBoostData.dotAngleDeg}deg, ${this.currentBoostData.dotDistance * 100}%, 55%)`; + const dotColorSec = `hsl(${this.currentBoostData.dotAngleDeg + this.currentBoostData.secondaryDotAngleDegDelta}deg, ${this.currentBoostData.dotDistance * 100}%, 20%)`; + + this.updateArcFill(cx, cy, radius, dotColor, dotColorSec); + } + + /** + * Updates the filled gradient arc between both color dots + * + * @param {number} cx - Half width of the gradient area + * @param {number} cy - Half height of the gradient area + * @param {number} radius - The target radius of the circle + * @param {string} color1 - Primary css color + * @param {string} color2 - Secondary css color + */ + updateArcFill(cx, cy, radius, color1, color2) { + const svg = this.doc.querySelector(".zen-boost-color-picker-arc-svg"); + + // Create SVG if it doesn't exist + if (!svg) { + this.initArcSVG(); + this.updateArcFill(cx, cy, radius, color1, color2); + return; + } + + const angle1 = this.currentBoostData.dotAngleDeg; + const angle2 = + this.currentBoostData.dotAngleDeg + + this.currentBoostData.secondaryDotAngleDegDelta; + const dist = this.currentBoostData.dotDistance; + const r = dist * radius; + const thickness = 2; + + const toXY = (deg, ra) => { + const rad = ((deg - 90) * Math.PI) / 180; + return [cx + ra * Math.cos(rad), cy + ra * Math.sin(rad)]; + }; + + const [x1, y1] = toXY(angle1, r); + const [x2, y2] = toXY(angle2, r); + + // Gradient endpoints for matched dot positions + const grad = svg.querySelector("#arc-gradient"); + grad.querySelector("#ag-stop1").setAttribute("stop-color", color1); + grad.querySelector("#ag-stop2").setAttribute("stop-color", color2); + grad.setAttribute("x1", x1); + grad.setAttribute("y1", y1); + grad.setAttribute("x2", x2); + grad.setAttribute("y2", y2); + + // Ring sector path + const outerR = r + thickness / 2; + const innerR = Math.max(r - thickness / 2, 1); + const delta = (angle2 - angle1 + 360) % 360; + const large = delta > 180 ? 1 : 0; + const [ox1, oy1] = toXY(angle1, outerR); + const [ox2, oy2] = toXY(angle2, outerR); + const [ix2, iy2] = toXY(angle2, innerR); + const [ix1, iy1] = toXY(angle1, innerR); + + const d = `M ${ox1} ${oy1} A ${outerR} ${outerR} 0 ${large} 1 ${ox2} ${oy2} L ${ix2} ${iy2} A ${innerR} ${innerR} 0 ${large} 0 ${ix1} ${iy1} Z`; + svg.querySelector(".arc-fill").setAttribute("d", d); + } + + /** + * Initializes the filled gradient arc between both color picker dots in form of a svg + */ + initArcSVG() { + const NS = "http://www.w3.org/2000/svg"; + const container = this.doc.querySelector( + ".zen-boost-color-picker-gradient" + ); + + if (!container.clientWidth || !container.clientHeight) { + return; + } + + const w = container.clientWidth; + const h = container.clientHeight; + + const svg = this.doc.createElementNS(NS, "svg"); + svg.classList.add("zen-boost-color-picker-arc-svg"); + svg.setAttribute("width", w); + svg.setAttribute("height", h); + svg.setAttribute("viewBox", `0 0 ${w} ${h}`); + svg.style.cssText = + "position:absolute; top:0; left:0; pointer-events:none; z-index:3;"; + + const defs = this.doc.createElementNS(NS, "defs"); + const grad = this.doc.createElementNS(NS, "linearGradient"); + grad.setAttribute("id", "arc-gradient"); + grad.setAttribute("gradientUnits", "userSpaceOnUse"); + + const stop1 = this.doc.createElementNS(NS, "stop"); + stop1.setAttribute("id", "ag-stop1"); + stop1.setAttribute("offset", "0%"); + + const stop2 = this.doc.createElementNS(NS, "stop"); + stop2.setAttribute("id", "ag-stop2"); + stop2.setAttribute("offset", "100%"); + + grad.appendChild(stop1); + grad.appendChild(stop2); + defs.appendChild(grad); + svg.appendChild(defs); + + // Arc fill path + const arcFill = this.doc.createElementNS(NS, "path"); + arcFill.classList.add("arc-fill"); + arcFill.setAttribute("fill", "url(#arc-gradient)"); + arcFill.setAttribute("opacity", "0.65"); + svg.appendChild(arcFill); + + container.style.position = "relative"; + container.appendChild(svg); } /** @@ -829,8 +1076,72 @@ ${cssSelector} { * text case override value (none, upper, or lower). */ updateCaseButtonVisuals() { - const sizeValue = this.doc.getElementById("zen-boost-text-case-toggle"); - sizeValue.setAttribute("case-mode", this.currentBoostData.textCaseOverride); + const caseButton = this.doc.getElementById("zen-boost-case"); + const caseText = this.doc.getElementById("zen-boost-case-text"); + caseButton.setAttribute( + "case-mode", + this.currentBoostData.textCaseOverride + ); + + switch (this.currentBoostData.textCaseOverride) { + case "uppercase": + caseButton.setAttribute("mode", "orange"); + caseText.style.display = "none"; + break; + case "lowercase": + caseButton.setAttribute("mode", "orange-red"); + caseText.style.display = "none"; + break; + case "capitalize": + caseButton.setAttribute("mode", "red"); + caseText.style.display = "none"; + break; + default: + caseButton.setAttribute("mode", "none"); + caseText.style.display = "initial"; + break; + } + } + + /** + * Updates the visual state of the text case toggle button based on the current + * text case override value (none, upper, or lower). + */ + updateSizeButtonVisuals() { + const sizeButton = this.doc.getElementById("zen-boost-size"); + const sizeText = this.doc.getElementById("zen-boost-size-text"); + const sizeValue = this.doc.getElementById("zen-boost-size-value"); + + switch (this.currentBoostData.sizeOverride) { + case 1: + sizeButton.setAttribute("mode", "none"); + sizeText.style.display = "initial"; + sizeValue.style.display = "none"; + break; + case 1.1: + sizeButton.setAttribute("mode", "orange"); + sizeText.style.display = "none"; + sizeValue.style.display = "initial"; + break; + case 1.25: + sizeButton.setAttribute("mode", "orange-red"); + sizeText.style.display = "none"; + sizeValue.style.display = "initial"; + break; + case 1.5: + sizeButton.setAttribute("mode", "red"); + sizeText.style.display = "none"; + sizeValue.style.display = "initial"; + break; + case 0.9: + sizeButton.setAttribute("mode", "blue"); + sizeText.style.display = "none"; + sizeValue.style.display = "initial"; + break; + } + sizeValue.setHTML( + `${Math.round(this.currentBoostData.sizeOverride * 100)}%` + ); } /** @@ -1199,7 +1510,7 @@ ${cssSelector} { Math.round(rect.top + Math.random() * rect.height), true ); - + this.currentBoostData.secondaryDotAngleDegDelta = Math.random() * 360; this.currentBoostData.changeWasMade = true; this.updateCurrentBoost(); @@ -1260,7 +1571,22 @@ ${cssSelector} { updateAllVisuals() { this.doc.getElementById("zen-boost-name-text").textContent = this.currentBoostData.boostName; - const dot = this.doc.querySelector(".zen-boost-color-picker-dot"); + const dot = this.doc.querySelector("#zen-boost-color-picker-dot-primary"); + const dotSec = this.doc.querySelector( + "#zen-boost-color-picker-dot-secondary" + ); + + if (!this.currentBoostData.sizeOverride) { + this.currentBoostData.sizeOverride = 1; + } + + if ( + !this.currentBoostData.secondaryDotPos || + !this.currentBoostData.secondaryDotPos.x || + !this.currentBoostData.secondaryDotPos.y + ) { + this.resetSecondaryDotPosition(); + } if ( this.currentBoostData.dotPos.x == null || @@ -1290,16 +1616,22 @@ ${cssSelector} { // Convert normalized position to relative position const xPos = this.currentBoostData.dotPos.x * rect.width; const yPos = this.currentBoostData.dotPos.y * rect.height; + const xPosSec = this.currentBoostData.secondaryDotPos.x * rect.width; + const yPosSec = this.currentBoostData.secondaryDotPos.y * rect.height; dot.setAttribute("animated", "true"); dot.style.left = `${xPos}px`; dot.style.top = `${yPos}px`; + dotSec.setAttribute("animated", "true"); + dotSec.style.left = `${xPosSec}px`; + dotSec.style.top = `${yPosSec}px`; } this.editorWindow._editor.setText(this.currentBoostData.customCSS || ""); this.updateFontButtonVisuals(); this.updateCaseButtonVisuals(); + this.updateSizeButtonVisuals(); this.updateColorControlSliderVisuals(); this.updateButtonToggleVisuals(); this.updateDot(); diff --git a/src/zen/boosts/ZenBoostsManager.sys.mjs b/src/zen/boosts/ZenBoostsManager.sys.mjs index 92cff3013..2cf27c28e 100644 --- a/src/zen/boosts/ZenBoostsManager.sys.mjs +++ b/src/zen/boosts/ZenBoostsManager.sys.mjs @@ -118,6 +118,9 @@ class nsZenBoostsManager { dotPos: { x: null, y: null }, dotDistance: 0, + secondaryDotAngleDegDelta: 32, + secondaryDotPos: { x: null, y: null }, + brightness: 0.5, saturation: 0.5, contrast: 0.75, @@ -131,6 +134,7 @@ class nsZenBoostsManager { autoTheme: false, textCaseOverride: "none", + sizeOverride: 1, zapSelectors: [], customCSS: "", diff --git a/src/zen/boosts/actors/ZenBoostsChild.sys.mjs b/src/zen/boosts/actors/ZenBoostsChild.sys.mjs index 4217c3053..1ea6b1e60 100644 --- a/src/zen/boosts/actors/ZenBoostsChild.sys.mjs +++ b/src/zen/boosts/actors/ZenBoostsChild.sys.mjs @@ -96,6 +96,25 @@ export class ZenBoostsChild extends JSWindowActorChild { return ((contrast << 24) | (b << 16) | (g << 8) | r) >>> 0; } + /** + * Builds the packed primary accent NSColor for a boost, with the boost + * contrast stored in the alpha byte. The complementary accent is not a + * separate color: the backend derives it by rotating this accent's hue by + * the boost's `secondaryDotAngleDegDelta`, which is sent separately. + * + * @param {number} hueDeg - Primary hue in degrees. + * @param {number} sat - Saturation in [0, 1]. + * @param {number} light - Lightness in [0, 1]. + * @param {object} boostData - The current boost data. + * @returns {number} The packed primary NSColor. + */ + #buildBoostColor(hueDeg, sat, light, boostData) { + return this.#rgbToNSColor( + this.#hslToRgb(hueDeg / 360, sat, light), + (1 - boostData.contrast) * 255 + ); + } + /** * From ZenGradientGenerator.mjs * Converts an HSL color value to RGB. Conversion formula @@ -345,6 +364,7 @@ export class ZenBoostsChild extends JSWindowActorChild { browsingContext.isZenBoostsInverted = boostData.smartInvert; if (boostData.enableColorBoost) { + let primaryColor; if (boostData.autoTheme) { // Workspace color is converted to the HSL color space let primaryGradientColor = boost.workspaceGradient[0]?.c ?? [ @@ -362,40 +382,34 @@ export class ZenBoostsChild extends JSWindowActorChild { // Workspace color is converted back to rgb // using the same modifiers as the color above - primaryGradientColor = this.#hslToRgb( - primaryGradientColor[0] / 360, + primaryColor = this.#buildBoostColor( + primaryGradientColor[0], primaryGradientColor[1] * (1 - boostData.saturation), - 0.1 + primaryGradientColor[2] * 0.9 * boostData.brightness + 0.1 + primaryGradientColor[2] * 0.9 * boostData.brightness, + boostData ); - - const rgbColor = primaryGradientColor; - const nsColor = this.#rgbToNSColor( - rgbColor, - (1 - boostData.contrast) * 255 - ); - browsingContext.zenBoostsData = nsColor; } else { - let colorWheelColor = this.#hslToRgb( - boostData.dotAngleDeg / 360, + primaryColor = this.#buildBoostColor( + boostData.dotAngleDeg, /* already is [0, 1] */ boostData.dotDistance * (1 - boostData.saturation), /* lightness range from [0.1, 0.9] */ - 0.1 + boostData.dotDistance * 0.8 * boostData.brightness + 0.1 + boostData.dotDistance * 0.8 * boostData.brightness, + boostData ); - - const rgbColor = colorWheelColor; - const nsColor = this.#rgbToNSColor( - rgbColor, - (1 - boostData.contrast) * 255 - ); - browsingContext.zenBoostsData = nsColor; } + browsingContext.zenBoostsData = primaryColor; + // The complementary accent is derived in the backend by rotating the + // primary accent's hue by this delta (in degrees). + browsingContext.zenBoostsComplementaryRotation = + boostData.secondaryDotAngleDegDelta ?? 0; return; } } else { browsingContext.isZenBoostsInverted = false; } browsingContext.zenBoostsData = 0; + browsingContext.zenBoostsComplementaryRotation = 0; } /** diff --git a/src/zen/boosts/nsZenBCOverrides.cpp b/src/zen/boosts/nsZenBCOverrides.cpp index 89c75b04e..0be59563e 100644 --- a/src/zen/boosts/nsZenBCOverrides.cpp +++ b/src/zen/boosts/nsZenBCOverrides.cpp @@ -66,6 +66,24 @@ void BrowsingContext::DidSet(FieldIndex, TRIGGER_PRES_CONTEXT_RESTYLE(); } +/** + * @brief Called when the ZenBoostsComplementaryRotation field is set on a + * browsing context. This is the hue rotation (in degrees) applied to the base + * accent to derive the complementary accent that light page colors are tinted + * toward. Triggers a restyle if it has changed. + * @param aOldValue The previous rotation value. + */ +void BrowsingContext::DidSet(FieldIndex, + float aOldValue) { + MOZ_ASSERT(IsTop()); + if (ZenBoostsComplementaryRotation() == aOldValue) { + return; + } + RefreshBoostCacheIfMatchesCurrent(this); + PresContextAffectingFieldChanged(); + TRIGGER_PRES_CONTEXT_RESTYLE(); +} + /** * @brief Called when the IsZenBoostsInverted field is set on a browsing * context. Triggers a restyle if the value has changed. diff --git a/src/zen/boosts/nsZenBoostsBackend.cpp b/src/zen/boosts/nsZenBoostsBackend.cpp index 9c545ddf4..511c0c5d2 100644 --- a/src/zen/boosts/nsZenBoostsBackend.cpp +++ b/src/zen/boosts/nsZenBoostsBackend.cpp @@ -47,6 +47,8 @@ namespace zen { nsZenAccentOklab nsZenBoostsBackend::mCachedAccent{0}; +nsZenAccentOklab nsZenBoostsBackend::mCachedComplementary{0}; +float nsZenBoostsBackend::mCachedComplementaryRotationDeg = 0.0f; namespace { @@ -128,17 +130,48 @@ inline static auto zenPrecomputeAccent(nscolor aAccentColor) { } /** - * @brief Applies a color filter to transform an original color toward an accent - * color. Preserves the original color's perceived luminance while shifting - * hue/chroma toward the accent. Uses the alpha channel of the accent color to - * store contrast information. + * @brief Derives the complementary accent from the base accent by rotating its + * hue in the Oklab a/b plane by the given angle. Lightness, contrast and the + * source nscolor are kept; only the hue changes. A zero rotation returns the + * base accent unchanged so the duotone collapses to a single-accent tint. + * @param aBase The precomputed base accent. + * @param aRotationDeg The hue rotation to apply, in degrees. + * @return The complementary accent. + */ +ZEN_HOT_FUNCTION +inline static nsZenAccentOklab zenRotateAccent(const nsZenAccentOklab& aBase, + float aRotationDeg) { + constexpr float kDegToRad = 3.14159265358979323846f / 180.0f; + const float angle = aRotationDeg * kDegToRad; + const float cosR = std::cos(angle); + const float sinR = std::sin(angle); + return nsZenAccentOklab{ + .accentNS = aBase.accentNS, + .accL = aBase.accL, + .accA = aBase.accA * cosR - aBase.accB * sinR, + .accB = aBase.accA * sinR + aBase.accB * cosR, + .contrastFactor = aBase.contrastFactor, + }; +} + +/** + * @brief Applies a duotone color filter to transform an original color toward + * one of two accent colors. The original color's perceived lightness decides + * which accent it is tinted toward: dark colors are pulled to the base accent, + * light colors to the complementary accent, with a smooth crossfade between + * them. The contrast value (stored in the accent's alpha channel) controls both + * the overall tint strength and how hard that dark/light split is. The + * original color's perceived luminance is otherwise preserved. * @param aOriginalColor The original color to filter. - * @param aAccentColor The accent color to filter toward (alpha channel contains - * contrast value). + * @param aAccent The base accent, tinted toward by dark colors (alpha channel + * contains the contrast value). + * @param aComplementary The complementary accent, tinted toward by light + * colors. * @return The filtered color with transformations applied. */ [[nodiscard]] ZEN_HOT_FUNCTION static inline nscolor zenFilterColorChannel( - nscolor aOriginalColor, const nsZenAccentOklab& aAccent) { + nscolor aOriginalColor, const nsZenAccentOklab& aAccent, + const nsZenAccentOklab& aComplementary) { const uint8_t oL = NS_GET_A(aOriginalColor); const uint8_t contrast = NS_GET_CONTRAST(aAccent.accentNS); if (oL == 0) { @@ -168,23 +201,40 @@ inline static auto zenPrecomputeAccent(nscolor aAccentColor) { const float origB = 0.0259040371f * l_ + 0.7827717662f * m_ - 0.8086757660f * s_; - // Blend chroma toward accent - const float bA = origA + (aAccent.accA - origA) * blendFactor; - const float bB = origB + (aAccent.accB - origB) * blendFactor; + // Duotone selection. origL is the original color's Oklab lightness (~0..1). + // A smoothstep around a fixed mid-lightness pivot crossfades from the base + // accent (dark colors, t=0) to the complementary accent (light colors, t=1). + // A stronger tint (higher blendFactor) narrows the crossfade band toward a + // hard two-tone split; a weaker one keeps it a gentle gradient. + constexpr float kPivot = 0.5f; + const float halfWidth = std::clamp(0.5f - blendFactor * 0.45f, 0.05f, 0.5f); + float t = std::clamp((origL - (kPivot - halfWidth)) / (2.0f * halfWidth), + 0.0f, 1.0f); + t = t * t * (3.0f - 2.0f * t); + + const float selA = aAccent.accA + (aComplementary.accA - aAccent.accA) * t; + const float selB = aAccent.accB + (aComplementary.accB - aAccent.accB) * t; + const float selL = aAccent.accL + (aComplementary.accL - aAccent.accL) * t; + const float selContrastFactor = + aAccent.contrastFactor + + (aComplementary.contrastFactor - aAccent.contrastFactor) * t; + + // Blend chroma toward the selected accent + const float bA = origA + (selA - origA) * blendFactor; + const float bB = origB + (selB - origB) * blendFactor; // Luminance: at low contrast stay near the original, the higher the contrast, // the more we shift toward the accent luminance, but we never go fully to // the accent luminance to preserve some of the original color's character. - const float lumDelta = aAccent.accL - origL; - const float fL = - origL + lumDelta * (blendFactor * aAccent.contrastFactor * 0.5f); + const float lumDelta = selL - origL; + const float fL = origL + lumDelta * (blendFactor * selContrastFactor * 0.5f); // Rotate hue in the Oklab a/b plane. Direction follows the luminance shift: // pushing darker rotates clockwise ("right"), pushing lighter rotates the // other way. Magnitude scales with blend strength so subtle accents stay // subtle. const float rotAngle = (lumDelta > 0.0f ? -1.0f : 1.0f) * blendFactor * - aAccent.contrastFactor * 0.25f; + selContrastFactor * 0.25f; const float cosR = std::cos(rotAngle); const float sinR = std::sin(rotAngle); const float fA = bA * cosR - bB * sinR; @@ -267,7 +317,7 @@ inline static nscolor zenInvertColorChannel(nscolor aColor) { */ ZEN_HOT_FUNCTION inline static void GetZenBoostsDataFromBrowsingContext( - ZenBoostData* aData, bool* aIsInverted, + ZenBoostData* aData, float* aComplementaryRotation, bool* aIsInverted, nsPresContext* aPresContext = nullptr) { auto zenBoosts = nsZenBoostsBackend::GetInstance(); if (!zenBoosts || (zenBoosts->mCurrentFrameIsAnonymousContent && @@ -276,6 +326,7 @@ inline static void GetZenBoostsDataFromBrowsingContext( } if (!aPresContext) { *aData = zenBoosts->mCachedCurrentAccent; + *aComplementaryRotation = zenBoosts->mCachedCurrentComplementaryRotation; *aIsInverted = zenBoosts->mCachedCurrentInverted; return; } @@ -288,6 +339,7 @@ inline static void GetZenBoostsDataFromBrowsingContext( } browsingContext = browsingContext->Top(); *aData = browsingContext->ZenBoostsData(); + *aComplementaryRotation = browsingContext->ZenBoostsComplementaryRotation(); *aIsInverted = browsingContext->IsZenBoostsInverted(); } @@ -329,11 +381,13 @@ auto nsZenBoostsBackend::onPresShellEntered(mozilla::dom::Document* aDocument) auto nsZenBoostsBackend::RefreshCachedBoostState() -> void { if (!mCurrentBrowsingContext) { mCachedCurrentAccent = 0; + mCachedCurrentComplementaryRotation = 0.0f; mCachedCurrentInverted = false; return; } auto top = mCurrentBrowsingContext->Top(); mCachedCurrentAccent = top->ZenBoostsData(); + mCachedCurrentComplementaryRotation = top->ZenBoostsComplementaryRotation(); mCachedCurrentInverted = top->IsZenBoostsInverted(); } @@ -342,19 +396,31 @@ nsZenBoostsBackend::FilterColorFromPresContext(nscolor aColor, nsPresContext* aPresContext) -> nscolor { ZenBoostData accentNS = 0; + float complementaryRotation = 0.0f; bool invertColors = false; - GetZenBoostsDataFromBrowsingContext(&accentNS, &invertColors, aPresContext); + GetZenBoostsDataFromBrowsingContext(&accentNS, &complementaryRotation, + &invertColors, aPresContext); if (accentNS) { if (mCachedAccent.accentNS != accentNS) { mCachedAccent = zenPrecomputeAccent(accentNS); + // Trigger a recompute of the complementary accent since + / it depends on the base accent.mCachedComplementary.accentNS = 0; + } + // Derive the complementary accent by rotating the base accent's hue by the + // boost's complementary rotation. Cached so the per-color hot path only + // recomputes it when the base accent or rotation changes. + if (mCachedComplementary.accentNS != accentNS || + mCachedComplementaryRotationDeg != complementaryRotation) { + mCachedComplementary = + zenRotateAccent(mCachedAccent, complementaryRotation); + mCachedComplementaryRotationDeg = complementaryRotation; } // Apply a filter-like tint: // - Preserve the original color's perceived luminance - // - Map hue/chroma toward the accent by scaling the accent's RGB - // to match the original luminance + // - Map hue/chroma toward the base or complementary accent depending on + // the original color's lightness // - Keep the original alpha - // Convert both colors to nscolor to access channels - aColor = zenFilterColorChannel(aColor, mCachedAccent); + aColor = zenFilterColorChannel(aColor, mCachedAccent, mCachedComplementary); } if (invertColors) { aColor = zenInvertColorChannel(aColor); diff --git a/src/zen/boosts/nsZenBoostsBackend.h b/src/zen/boosts/nsZenBoostsBackend.h index b79d7ef4e..4d7bcfe10 100644 --- a/src/zen/boosts/nsZenBoostsBackend.h +++ b/src/zen/boosts/nsZenBoostsBackend.h @@ -83,6 +83,10 @@ class nsZenBoostsBackend final { * resolve. */ ZenBoostData mCachedCurrentAccent = 0; + // Hue rotation in degrees applied to the base accent to derive the + // complementary accent. Zero means the complementary accent equals the base + // accent (the duotone collapses to a single-accent tint). + float mCachedCurrentComplementaryRotation = 0.0f; bool mCachedCurrentInverted = false; private: @@ -92,6 +96,10 @@ class nsZenBoostsBackend final { RefPtr mCurrentBrowsingContext; static nsZenAccentOklab mCachedAccent; + // Base accent with its Oklab hue rotated by mCachedComplementaryRotationDeg, + // recomputed only when the base accent or rotation changes. + static nsZenAccentOklab mCachedComplementary; + static float mCachedComplementaryRotationDeg; public: /** diff --git a/src/zen/boosts/zen-boost-editor.inc.xhtml b/src/zen/boosts/zen-boost-editor.inc.xhtml index c3a857092..cba58dbce 100644 --- a/src/zen/boosts/zen-boost-editor.inc.xhtml +++ b/src/zen/boosts/zen-boost-editor.inc.xhtml @@ -57,7 +57,8 @@ - + + @@ -69,17 +70,26 @@ - +# Font buttons will be injected here - +# Additional font options will be injected here - + + + + + - + + - - + diff --git a/src/zen/boosts/zen-boosts.css b/src/zen/boosts/zen-boosts.css index da851c036..44b84ea46 100644 --- a/src/zen/boosts/zen-boosts.css +++ b/src/zen/boosts/zen-boosts.css @@ -302,17 +302,6 @@ body { } } -#zen-boost-size { - list-style-type: none; - - & #zen-boost-size-value { - text-align: right; - right: 8px; - margin: auto; - position: relative; - } -} - .zen-boost-panel-disabled { filter: grayscale(1); } @@ -348,18 +337,19 @@ body { } } -#zen-boost-text-case-toggle[case-mode="none"] { - opacity: 0.5; - &:hover { - opacity: 0.6 !important; - } -} - -#zen-boost-text-case-toggle:not([case-mode="none"]) { +#zen-boost-case:not([case-mode="none"]) { background-color: #ebebed; font-weight: 600 !important; } +#zen-boost-case[case-mode="uppercase"] { + text-transform: uppercase; +} + +#zen-boost-case[case-mode="lowercase"] { + text-transform: lowercase; +} + #zen-boost-code-top-bar .mod-button { height: auto !important; } @@ -434,6 +424,11 @@ body { } } +.footer { + background-color: #F6F6F8; + padding: 20px; +} + #zen-boost-toolbar-wrapper, #zen-boost-toolbar-wrapper-colors { width: 100%; @@ -450,11 +445,18 @@ body { & .med { margin: 0; flex: 1 1 50%; + + & p { + text-align: center; + width: 100%; + text-indent: initial; + margin: auto; + } } } #zen-boost-font-select { - width: 95px; + width: 120px; height: 20px !important; transition: 0.2s opacity ease-in-out; @@ -693,10 +695,15 @@ body { border-radius: 100%; } + & .zen-boost-color-picker-arc-svg { + opacity: 0; + transition: opacity 0.4s ease; + } + & .zen-boost-color-picker-dot { box-shadow: 0 2px 4px #00000022; position: absolute; - z-index: 4; + z-index: 5; width: 24px; height: 24px; border-radius: 50%; @@ -707,16 +714,15 @@ body { cursor: pointer; border: 3px solid #ffffff; transform: translate(-50%, -50%); - pointer-events: none; transform-origin: top left; + pointer-events: all; &:first-of-type { width: 32px; height: 32px; border-width: 3px; - pointer-events: all; transition: transform 0.2s; - z-index: 999; + z-index: 4; &:hover { transform: scale(1.05) translate(-50%, -50%); } @@ -729,12 +735,11 @@ body { } #zen-boost-editor-root:hover { - & .zen-boost-color-picker-circle { + & .zen-boost-color-picker-circle, .zen-boost-color-picker-arc-svg { opacity: 0.4; } } - .zen-boost-font-button-active { background-color: #5454572f !important; } diff --git a/src/zen/tests/mochitests/sandbox/browser.toml b/src/zen/tests/mochitests/sandbox/browser.toml index 86c0cdbbd..f12d053b9 100644 --- a/src/zen/tests/mochitests/sandbox/browser.toml +++ b/src/zen/tests/mochitests/sandbox/browser.toml @@ -36,8 +36,6 @@ skip-if = [ ["browser_sandbox_test.js"] skip-if = [ - "os == 'linux' && os_version == '22.04' && arch == 'x86_64' && display == 'wayland' && artifact && debug", # bug 1945658 - "os == 'linux' && os_version == '24.04' && arch == 'x86_64' && display == 'x11' && artifact && debug", # bug 1945658 "os == 'win' && os_version == '11.26200' && arch == 'x86' && debug", # bug 2028636 "os == 'win' && os_version == '11.26200' && arch == 'x86_64' && debug", # bug 2028636 ]