Skip to content

Commit

Permalink
Enso color picker (#9825)
Browse files Browse the repository at this point in the history
New color picker, designed for Enso:

https://github.com/enso-org/enso/assets/1047859/c3eff168-6807-4825-b17b-053e3cd8b04c

- Colors never clash: OKLCH lightness and chroma are fixed.
- Easily match colors: Colors of other nodes in the current method are expanded to slices of the color wheel.

Closes #9613.
  • Loading branch information
kazcw committed May 6, 2024
1 parent 36dcbf1 commit 01a2ca4
Show file tree
Hide file tree
Showing 18 changed files with 617 additions and 171 deletions.
1 change: 0 additions & 1 deletion app/gui2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@
"rimraf": "^5.0.5",
"semver": "^7.5.4",
"sucrase": "^3.34.0",
"verte-vue3": "^1.1.1",
"vue": "^3.4.19",
"ws": "^8.13.0",
"y-codemirror.next": "^0.3.2",
Expand Down
8 changes: 8 additions & 0 deletions app/gui2/shared/util/data/iterable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ export class Resumable<T> {
this.current = this.iterator.next()
}

peek() {
return this.current.done ? undefined : this.current.value
}

advance() {
this.current = this.iterator.next()
}

/** The given function peeks at the current value. If the function returns `true`, the current value will be advanced
* and the function called again; if it returns `false`, the peeked value remains current and `advanceWhile` returns.
*/
Expand Down
46 changes: 36 additions & 10 deletions app/gui2/src/components/CircularMenu.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
<script setup lang="ts">
import ColorRing from '@/components/ColorRing.vue'
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
import SmallPlusButton from '@/components/SmallPlusButton.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import ToggleIcon from '@/components/ToggleIcon.vue'
import { ref } from 'vue'
const nodeColor = defineModel<string | undefined>('nodeColor')
const props = defineProps<{
isRecordingEnabledGlobally: boolean
isRecordingOverridden: boolean
isDocsVisible: boolean
isVisualizationVisible: boolean
isFullMenuVisible: boolean
visibleNodeColors: Set<string>
}>()
const emit = defineEmits<{
'update:isRecordingOverridden': [isRecordingOverridden: boolean]
Expand All @@ -20,13 +24,18 @@ const emit = defineEmits<{
openFullMenu: []
delete: []
createNodes: [options: NodeCreationOptions[]]
toggleColorPicker: []
}>()
const showColorPicker = ref(false)
</script>

<template>
<div class="CircularMenu" @pointerdown.stop @pointerup.stop @click.stop>
<div class="circle" :class="`${props.isFullMenuVisible ? 'full' : 'partial'}`">
<div
v-if="!showColorPicker"
class="circle menu"
:class="`${props.isFullMenuVisible ? 'full' : 'partial'}`"
>
<div v-if="!isFullMenuVisible" class="More" @pointerdown.stop="emit('openFullMenu')"></div>
<SvgIcon
v-if="isFullMenuVisible"
Expand All @@ -40,7 +49,7 @@ const emit = defineEmits<{
name="paint_palette"
class="icon-container button slot3"
:alt="`Choose color`"
@click.stop="emit('toggleColorPicker')"
@click.stop="showColorPicker = true"
/>
<SvgIcon
v-if="isFullMenuVisible"
Expand Down Expand Up @@ -72,6 +81,13 @@ const emit = defineEmits<{
@update:modelValue="emit('update:isRecordingOverridden', $event)"
/>
</div>
<div v-if="showColorPicker" class="circle">
<ColorRing
v-model="nodeColor"
:matchableColors="visibleNodeColors"
@close="showColorPicker = false"
/>
</div>
<SmallPlusButton
v-if="!isVisualizationVisible"
class="below-slot5"
Expand All @@ -85,15 +101,24 @@ const emit = defineEmits<{
position: absolute;
user-select: none;
pointer-events: none;
/* This is a variable so that it can be referenced in computations,
but currently it can't be changed due to many hard-coded values below. */
--outer-diameter: 104px;
--full-ring-path: path(
evenodd,
'M0,52 A52,52 0,1,1 104,52 A52,52 0,1,1 0, 52 z m52,20 A20,20 0,1,1 52,32 20,20 0,1,1 52,72 z'
);
}
.circle {
position: relative;
left: -36px;
top: -36px;
width: 114px;
height: 114px;
width: var(--outer-diameter);
height: var(--outer-diameter);
}
.circle.menu {
> * {
pointer-events: all;
}
Expand All @@ -118,10 +143,7 @@ const emit = defineEmits<{
}
&.full {
&:before {
clip-path: path(
evenodd,
'M0,52 A52,52 0,1,1 104,52 A52,52 0,1,1 0, 52 z m52,20 A20,20 0,1,1 52,32 20,20 0,1,1 52,72 z'
);
clip-path: var(--full-ring-path);
}
}
}
Expand Down Expand Up @@ -153,6 +175,10 @@ const emit = defineEmits<{
}
}
:deep(.ColorRing .gradient) {
clip-path: var(--full-ring-path);
}
.icon-container {
display: inline-flex;
background: none;
Expand Down Expand Up @@ -220,7 +246,7 @@ const emit = defineEmits<{
.below-slot5 {
position: absolute;
top: calc(108px - 36px);
top: calc(var(--outer-diameter) - 32px);
pointer-events: all;
}
Expand Down
46 changes: 0 additions & 46 deletions app/gui2/src/components/ColorPicker.vue

This file was deleted.

193 changes: 193 additions & 0 deletions app/gui2/src/components/ColorRing.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
<script setup lang="ts">
import {
cssAngularColorStop,
gradientPoints,
rangesForInputs,
} from '@/components/ColorRing/gradient'
import { injectInteractionHandler } from '@/providers/interactionHandler'
import { targetIsOutside } from '@/util/autoBlur'
import { cssSupported, ensoColor, formatCssColor, parseCssColor } from '@/util/colors'
import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import { computed, onMounted, ref } from 'vue'
/**
* Hue picker
*
* # Angles
*
* All angles are measured in turns, starting from the 12-o'clock position, normalized to the range 0-1, unless
* otherwise specified.
* - This is the axis used by CSS gradients (adjustment is necessary when working with trigonometric functions, which
* start from the positive x-axis).
* - Turns allow constants to be expressed as simple numbers, and can be easily converted to the units used by external
* APIs (radians for math, degrees for culori).
*/
// If the browser doesn't support OKLCH gradient interpolation, the gradient will be specified by computing the number
// of points specified here in OKLCH, converting to sRGB if the browser doesn't support OKLCH colors at all, and
// interpolating in sRGB. This number has been found to be enough to look close to the intended colors, without
// excessive gradient complexity (which may affect performance).
const NONNATIVE_OKLCH_INTERPOLATION_STEPS = 12
const FIXED_RANGE_WIDTH = 1 / 16
const selectedColor = defineModel<string | undefined>()
const props = defineProps<{
matchableColors: Set<string>
}>()
const emit = defineEmits<{
close: []
}>()
const browserSupportsOklchInterpolation = cssSupported(
'background-image: conic-gradient(in oklch increasing hue, red, blue)',
)
const svgElement = ref<HTMLElement>()
const interaction = injectInteractionHandler()
onMounted(() => {
interaction.setCurrent({
cancel: () => emit('close'),
pointerdown: (e: PointerEvent) => {
if (targetIsOutside(e, svgElement.value)) emit('close')
return false
},
})
})
const mouseSelectedAngle = ref<number>()
const triangleAngle = computed(() => {
if (mouseSelectedAngle.value) return mouseSelectedAngle.value
if (selectedColor.value) {
const color = parseCssColor(selectedColor.value)
if (color?.h) return color.h / 360
}
return undefined
})
function cssColor(hue: number) {
return formatCssColor(ensoColor(hue))
}
// === Events ===
function eventAngle(event: MouseEvent) {
if (!svgElement.value) return 0
const origin = Rect.FromDomRect(svgElement.value.getBoundingClientRect()).center()
const offset = Vec2.FromXY(event).sub(origin)
return Math.atan2(offset.y, offset.x) / (2 * Math.PI) + 0.25
}
function ringHover(event: MouseEvent) {
mouseSelectedAngle.value = eventAngle(event)
}
function ringClick(event: MouseEvent) {
mouseSelectedAngle.value = eventAngle(event)
if (triangleHue.value != null) selectedColor.value = cssColor(triangleHue.value)
emit('close')
}
// === Gradient colors ===
const fixedRanges = computed(() => {
const inputHues = new Set<number>()
for (const rawColor of props.matchableColors) {
if (rawColor === selectedColor.value) continue
const color = parseCssColor(rawColor)
const hueDeg = color?.h
if (hueDeg == null) continue
const hue = hueDeg / 360
inputHues.add(hue < 0 ? hue + 1 : hue)
}
return rangesForInputs(inputHues, FIXED_RANGE_WIDTH / 2)
})
const triangleHue = computed(() => {
const target = triangleAngle.value
if (target == null) return undefined
for (const range of fixedRanges.value) {
if (target < range.start) break
if (target <= range.end) return range.hue
}
return target
})
// === CSS ===
const cssGradient = computed(() => {
const points = gradientPoints(
fixedRanges.value,
browserSupportsOklchInterpolation ? 2 : NONNATIVE_OKLCH_INTERPOLATION_STEPS,
)
const angularColorStopList = Array.from(points, cssAngularColorStop)
const colorStops = angularColorStopList.join(',')
return browserSupportsOklchInterpolation ?
`conic-gradient(in oklch increasing hue,${colorStops})`
: `conic-gradient(${colorStops})`
})
const cssTriangleAngle = computed(() =>
triangleAngle.value != null ? `${triangleAngle.value}turn` : undefined,
)
const cssTriangleColor = computed(() =>
triangleHue.value != null ? cssColor(triangleHue.value) : undefined,
)
</script>

<template>
<div class="ColorRing">
<svg v-if="cssTriangleAngle != null" class="svg" viewBox="-2 -2 4 4">
<polygon class="triangle" points="0,-1 -0.4,-1.35 0.4,-1.35" />
</svg>
<div
ref="svgElement"
class="gradient"
@pointerleave="mouseSelectedAngle = undefined"
@pointermove="ringHover"
@click.stop="ringClick"
@pointerdown.stop
@pointerup.stop
/>
</div>
</template>

<style scoped>
.ColorRing {
position: relative;
pointer-events: none;
width: 100%;
height: 100%;
}
.svg {
position: absolute;
margin: -50%;
}
.gradient {
position: absolute;
inset: 0;
pointer-events: auto;
margin-top: auto;
background: v-bind('cssGradient');
cursor: crosshair;
border-radius: var(--radius-full);
animation: grow 0.1s forwards;
}
@keyframes grow {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
.triangle {
transform: rotate(v-bind('cssTriangleAngle'));
fill: v-bind('cssTriangleColor');
}
</style>
Loading

0 comments on commit 01a2ca4

Please sign in to comment.