Skip to content

eero-lehtinen/oklch-color-picker.nvim

Repository files navigation

oklch-color-picker.nvim

Sometimes the resolution of a cli just isn't enough

screenshot

Features

  • Select and edit buffer colors in a graphical picker
  • Fast async color highlighting
  • Supports multiple formats:
    • Hex (#RGB, #RGBA, #RRGGBB, #RRGGBBAA)
    • CSS (rgb(..), hsl(..), oklch(..))
    • Hex literal (0xRRGGBB, 0xAARRGGBB)
    • Tailwind (e.g. bg-red-800)
    • Can recognize any numbers in brackets as a color (e.g., vec3(0.5, 0.5, 0.5))
    • Custom formats can be defined
  • LSP colors
  • Integrated graphical color picker using the perceptual Oklch color space:
    • Consists of lightness, chroma, and hue for intuitive adjustments
    • Based on Oklab theory, using Lr as an improved lightness estimate
    • Vim-like navigation (click ? to view latest binds) (mouse is still recommended)

Installation

Requires Neovim 0.10+

lazy.nvim

{
  "eero-lehtinen/oklch-color-picker.nvim",
  event = "VeryLazy",
  version = "*",
  keys = {
    -- One handed keymap recommended, you will be using the mouse
    {
      "<leader>v",
      function() require("oklch-color-picker").pick_under_cursor() end,
      desc = "Color pick under cursor",
    },
  },
  ---@type oklch.Opts
  opts = {},
}

This plugin automatically downloads the picker application and a color parser library from the releases page of the picker application repository (it's open source too in a different repo!). The picker is a standalone ⚡Rust⚡ application with ⚡blazing fast⚡ performance and startup time. There are prebuilt binaries for Linux, macOS, and Windows.

Showcase

Video

demo.mp4

LSP features

Default Options

local default_opts = {
  highlight = {
    enabled = true,

    -- Async delay in ms.
    edit_delay = 60,
    -- Async delay in ms.
    scroll_delay = 0,

    -- Options: 'background'|'foreground'|'virtual_left'|'virtual_eol'|'foreground+virtual_left'|'foreground+virtual_eol'
    style = "background",
    bold = false,
    italic = false,
    -- `● ` also looks nice, nerd fonts also have bigger shapes ` `, `󰝤 `, and ` `.
    virtual_text = "",
    -- Less than user hl by default (:help vim.highlight.priorities)
    priority = 175,

    -- Prevent attaching to buffers with these filetypes.
    ignore_ft = { "blink-cmp-menu" },

    -- Tint the highlight background for 'foreground' and 'virtual' styles when the
    -- found color is too close to the editor background.
    -- Set `emphasis = false` to disable.
    emphasis = {
      -- Distance (0..1) to the editor background where emphasis activates
      -- (first item for dark themes, second for light ones).
      threshold = { 0.1, 0.17 },
      -- How much (0..255) to offset the color (first item for dark colors, second for light ones).
      amount = { 45, -80 },
    },

    -- List of LSP clients that are allowed to highlight colors:
    -- By default, only fairly performant and useful LSPs are enabled.
    -- Set `enabled_lsps = true` to enable all LSPs anyways.
    enabled_lsps = { "tailwindcss", "cssls", "css_variables" },
    -- Async delay in ms, LSPs also have their own latency.
    lsp_delay = 120,

    -- Disable builtin LSP colors introduced in Nvim 0.12 to avoid conflicts.
    disable_builtin_lsp_colors = true,
  },

  patterns = {
    hex = { priority = -1, "()#%x%x%x+%f[%W]()" },
    hex_literal = { priority = -1, "()0x%x%x%x%x%x%x+%f[%W]()" },

    -- Rgb and Hsl support modern and legacy formats:
    -- rgb(10 10 10 / 50%) and rgba(10, 10, 10, 0.5)
    css_rgb = { priority = -1, "()rgba?%(.-%)()" },
    css_hsl = { priority = -1, "()hsla?%(.-%)()" },
    css_oklch = { priority = -1, "()oklch%([^,]-%)()" },

    tailwind = {
      priority = -2,
      custom_parse = tailwind.custom_parse,
      "%f[%w][%l%-]-%-()%l-%-%d%d%d?%f[%W]()",
    },

    -- Allows any digits, dots, commas or whitespace within brackets.
    numbers_in_brackets = { priority = -10, "%(()[%d.,%s]+()%)" },
  },

  register_cmds = true,

  -- Download Rust binaries automatically.
  auto_download = true,

  -- Use the Windows version of the app on WSL instead of using unreliable WSLg.
  wsl_use_windows_app = true,

  log_level = vim.log.levels.INFO,
}

Configuration

Choose highlighting style:

Disable default patterns by setting them to false:

opts = {
  patterns = {
    numbers_in_brackets = false
  }
}

Define your own patterns:

opts = {
  patterns = {
    -- Example with all properties:
    glsl_vec_linear = {
      -- (Optional) Higher priority patterns are tried first. Defaults to 0.
      priority = 5,
      -- (Optional) Color format for the picker. Auto detect by default.
      format = "raw_rgb_float",
      -- (Optional) Filetypes to apply the pattern to. Must be a table.
      ft = { "glsl" },
      -- (Optional) Custom parser function for unsupported formats.
      custom_parse = function(match)
        -- Some parsing logic ...
        return 0xFFFFFF
      end,
      -- The list of patterns.
      -- Pattern reference at https://www.lua.org/manual/5.4/manual.html#6.4.1
      "vec3%(()[%d.,%s]+()%)", -- Gets `.1,.2,.3` from code `vec3(.1,.2,.3)`
      "vec4%(()[%d.,%s]+()%)",
    },

    -- Replace default css patterns to match only modern formats:
    -- (no "a" suffix in name or commas)
    css_rgb = { "()rgb%([^,]-%)()" },
    css_hsl = { "()hsl%([^,]-%)()" },
  }
}

API & Commands

Picking

-- Launch color picker for color under cursor.
require('oklch-color-picker').pick_under_cursor()
-- Force input format, useful for raw color types that can't be auto detected.
require('oklch-color-picker').pick_under_cursor({ force_format = "raw_oklch" })
-- Call `open_picker` as a fallback with default options if there was no color under the cursor.
require('oklch-color-picker').pick_under_cursor({ fallback_open = {} })

-- Open the picker ignoring whatever is under the cursor. Useful for inserting new colors.
require("oklch-color-picker").open_picker()

The command :ColorPickOklch can be used instead of pick_under_cursor().

Definitions

--- @param opts? picker.PickUnderCursorOpts
--- @return boolean success true if a color was found and picker opened
function pick_under_cursor(opts)

--- @param opts? picker.OpenPickerOpts
--- @return boolean success true if the picker was able to open
function open_picker(opts)

---@class picker.PickUnderCursorOpts
---@field force_format? string auto detect by default
---@field fallback_open? picker.OpenPickerOpts open the picker anyway if no color under the cursor is found

---@class picker.OpenPickerOpts
---@field initial_color? string any color that the picker can parse, e.g. "#fff" (uses a random hex color by default)
---@field force_format? string auto detect by default

Highlighting

-- Highlighting can be controlled at runtime:
require("oklch-color-picker").highlight.disable()
require("oklch-color-picker").highlight.enable()
require("oklch-color-picker").highlight.toggle()

Color Formats

The picker application supports the following formats: (hex, rgb, oklch, hsl, hex_literal, rgb_legacy, hsl_legacy, raw_rgb, raw_rgb_float, raw_rgb_linear, raw_oklch). Most of these are auto detected. E.g. CSS rgb values are detected because they are surrounded by rgb() and hex starts with #.

The raw formats are just lists of numbers separated by commas that can be used with any programming language. The picker auto detection assumes raw formats to be either integer 0-255 or float 0.0-1.0 srgb colors (formats raw_rgb or raw_rgb_float). For raw_rgb_linear or raw_oklch values you have to specify the format manually. Note that the picker accepts colors with and without alpha (3 or 4 numbers separated by commas).

Patterns

The patterns used are normal lua patterns. They are used to find colors from the buffer text, but they don't need to be exact because validation and parsing is done by the picker application.

CSS colors are mostly already supported, so you should probably only add raw color formats to better support the languages you use. The default numbers_in_brackets should already handle most needs, but you can still create your own patterns if you have linear colors or your function names clash with CSS.

The patterns should contain two empty groups () to designate the replacement range. E.g. vec3%(()[%d.,%s]+()%) will find .1,.2,.2 from within the text vec3(.1,.2,.3). [%d.,%s]+ means one or more digits, dots, commas or whitespace characters. Remember to escape literal brackets like this: %(.

Why is the highlighting async and just how fast is it?

I don't like how an insignificant feature like color highlighting can hog CPU resources and cause lag, so this plugin tries to make it fast and unnoticeable. The highlighting is done on a timer after edits to give the immediate CPU time to features you actually care about like Treesitter or LSP. Then after the timer delay has passed, the colors are searched and highlights applied. You can also set the delay to 0 to make highlighting instant.

Stress testing (2025-04-05)

Event oklch-color-picker.nvim nvim-colorizer.lua ccc.nvim nvim-highlight-colors
BufEnter 2.5 ms 3.0 ms 43.8 ms 8.2 ms
WinScrolled 0.1 – 0.7 ms 0.1 – 1.2 ms n/a 8.2 ms
TextChanged < 0.1 ms < 0.1 ms 0.9 ms n/a
InsertLeave n/a n/a n/a 8.2 ms

When you open a new buffer, visible lines are processed. With a AMD Ryzen 9 9950X, this takes around 0.2 ms on a 65 rows by 120 cols window, full of text and 10 hex colors. In the stress test file, where the window is filled with 975 hex colors, the initial update takes 2.5 ms, more than half of which is unavoidable Nvim extmark (highlight) creation and assignment overhead.

When scrolling, visible lines are processed incrementally. If you scroll 10 lines down, only those lines are processed. This means that scrolling between 1 and 65 lines can take 0.1 – 0.7 ms in the stress test file. Rehighlighting all visible lines takes 0.7 ms instead of the initial 2.5 ms because highlight groups are cached. Basically it's faster to see a color for the second time.

When editing, only the changed lines are updated. In the common case, when changing text on a line with no colors, the update takes < 0.01 ms (line being 120 chars wide). Doing the same in the stress test file takes < 0.1 ms. Of course with async, it takes zero time immediately after inserting text.

catgoose/nvim-colorizer.lua uses the same strategy as this plugin and processes visible lines with incremental scrolling. When opening the stress test file, the update takes 3.0 ms. Inserting in a line takes less than 0.1 ms. It also takes around 7 ms to do some startup logic the first time a file is opened, but that time was not counted in the timings. Named colors were disabled for fairness.

uga-rosa/ccc.nvim instead processes the whole file at startup, then updates only changed lines. The whole stress test file takes 43.8 ms to process when opening the buffer, scrolling is free and inserting in a single line takes around 0.9 ms. Hwb, lab, lch, oklab, and named colors were disabled for fairness.

brenoprata10/nvim-highlight-colors in the stress test takes 8.2 ms to do a full screen update. It doesn't do partial updates, so a full update is done every InsertLeave or WinScrolled event. Ansi colors, css variables, and named colors were disabled for fairness.

Measurements were done by manually adding vim.uv.hrtime logging to the update functions of each plugin, then doing the operation 10 times with the stress test file and taking the average of the results. You can check your own timings in this plugin by setting require("oklch-color-picker").highlight.set_perf_logging(true) (you can also check your LSP timings with set_lsp_perf_logging(true)).

Inspirations

About

A graphical color picker and highlighter for Neovim

Topics

Resources

License

Stars

Watchers

Forks

Languages