commit d4e057b2ee9c6d71e4b6c78edc4d4b91bac08bfa Author: mut-ex Date: Mon Aug 31 22:48:32 2020 -0400 Initial commit diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..09a1a32 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2020 mut-ex + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1b5506 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# :thumbsup:nice +nice is an easy to use, highly configurable extension for **[Awesome WM](https://awesomewm.org/)** that adds beautiful window decorations (and extra functionality!) to clients. It... + +* ...adds a **subtle 3D look**, and soft, **rounded anti-aliased, corners** to windows +* ...picks the window **decoration color based on the client content for a seamless look** , and **adjusts the window title text color** accordingly +* ...**auto-generates titlebar buttons** (and their states) for you based on the colors your pick *or* you can let it pick the colors for you! +* ...allows you to **customize** which **titlebar buttons** to include, their order, and their layout +* ...adds the **ability to maximize/unmaximize** floating windows by **double clicking the titlebar**, and of course, **moving them by clicking and holding** +* ...adds the ability to **"roll up"** and **"roll down"** the client window like a **window shade**! Scroll up over the titlebar to **instantly hide the window contents but keep the title bar** right where it is. And then either scroll down or click the titlebar to make the window contents visible again! + +![Preview](https://raw.githubusercontent.com/mut-ex/awesome-wm-nice/master/preview.png) + +## Getting Started + +### Prerequisites + +* You need to be using **[Awesome WM](https://awesomewm.org/)** as your window manager, and should already have a working basic configuration file. I have developed and tested nice only on **awesome v4.3 git version** + +* I **highly** suggest using a compositor such as **[picom](https://github.com/yshui/picom)**. My recommended shadow settings are given below + + ``` + shadow = true; + shadow-radius = 40; + shadow-opacity = .55; + shadow-offset-x = -40; + shadow-offset-y = -20; + shadow-exclude = [ + "_NET_WM_WINDOW_TYPE:a = '_NET_WM_WINDOW_TYPE_NOTIFICATION'", + "_NET_WM_STATE@:32a *= '_NET_WM_STATE_HIDDEN'", + "_GTK_FRAME_EXTENTS@:c" + ]; + ``` + +* For GTK apps, I suggest adding the following line to **~/.config/gtk-3.0/settings.ini** under the **[Settings]** section + + ``` + gtk-decoration-layout=menu: + ``` + + + +### Installation + +The easiest and quickest way to get started is by cloning this repository to your awesome configuration directory + +```shell +$ cd ~/.config/awesome +$ git clone https://github.com/mut-ex/awesome-wm-nice.git nice +``` + + + +## Usage + +To use nice, you first need to load the module. You can do so by placing the following line right after `beautiful.init(...)` + +```lua +local nice = require("nice") +nice() +``` + +If you are fine using the default configuration, you are all done! However if you like, you can override the defaults you wish to change by passing your own configuration. There are a lot of parameters you can change! The commented out lines in the code block before represent the default values + +```lua +local nice = require("nice") +nice = { + -- * == Titlebar specific == * + -- titlebar_color = "#1E1E24" + -- titlebar_height = 38 + -- titlebar_radius = 9 + -- titlebar_margin_left = 0 + -- titlebar_margin_right = 0 + -- titlebar_font = "Sans 10" + + -- titlebar_items = { + -- left = {"close", "minimize", "maximize"}, + -- middle = {"title"}, + -- right = {"sticky", "ontop", "floating"}, + -- } + + -- context_menu_theme = { + -- bg_normal = "#5e6472", + -- fg_normal = "#fefefa", + -- bg_focus = "#aed9e0", + -- fg_focus = "#242424", + -- border_color = "#00000000", + -- border_width = 0, + -- height = 35, + -- width = 250, + -- font = "Sans 10", + -- } + -- window_shade_enabled = false + + -- * == Button specific == * + -- button_margin_horizontal = 5 + -- button_margin_top = 2 + -- button_size = 16 + -- close_color = "#ee4266" + -- minimize_color = "#ffb400" + -- maximize_color = "#4CBB17" + -- floating_color = "#f6a2ed" + -- ontop_color = "#f6a2ed" + -- sticky_color = "#f6a2ed" + + -- tooltips_enabled = true + + -- tooltip_messages = { + -- close = "close", + -- minimize = "minimize", + -- floating_active = "enable tiling mode", + -- floating_inactive = "enable floating mode", + -- maximize_active = "unmaximize", + -- maximize_inactive = "maximize", + -- ontop_active = "don't keep above other windows", + -- ontop_inactive = "keep above other windows", + -- sticky_active = "disable sticky mode", + -- sticky_inactive = "enable sticky mode", + -- } +} +``` + + + +## License + +[![License](http://img.shields.io/:license-mit-blue.svg)](http://doge.mit-license.org) + + + diff --git a/color_rules b/color_rules new file mode 100644 index 0000000..658cc2e --- /dev/null +++ b/color_rules @@ -0,0 +1,25 @@ +return { +-- Table: {1} +{ + ["code-oss"]="#2d2b55", + ["nextcloud"]="#242424", + ["magnus"]="#2d2d2d", + ["Navigator"]="#2d234d", + ["firefox"]="#181818", + ["kitty"]="#202745", + ["simplescreenrecorder"]="#161925", + ["gimp-2.10"]="#454545", + ["balena-etcher"]="#4d5057", + ["eog"]="#2d2d2d", + ["ghb"]="#181818", + ["electron"]="#181818", + ["shutter"]="#181818", + ["typora"]="#2d2d2d", + ["Firefox"]="#181818", + ["keditfiletype5"]="#161925", + ["kvantummanager"]="#161925ff", + ["dolphin"]="#161925", + ["vlc"]="#161925", + ["org.gnome.Nautilus"]="#242424", +}, +} \ No newline at end of file diff --git a/colors.lua b/colors.lua new file mode 100644 index 0000000..20414c3 --- /dev/null +++ b/colors.lua @@ -0,0 +1,159 @@ +-- => Colors +-- Provides utility functions for handling colors +-- ============================================================ +local math = math +local floor = math.floor +local max = math.max +local min = math.min +local pow = math.pow +local random = math.random +local debug = require("helpers").debug + +-- Returns a value that is clipped to interval edges if it falls outside the interval +local function clip(num, min_num, max_num) return + max(min(num, max_num), min_num) end + +-- Converts the given hex color to normalized rgba +local function hex2rgb(color) + color = color:gsub("#", "") + local strlen = color:len() + if strlen == 6 then + return tonumber("0x" .. color:sub(1, 2)) / 255, + tonumber("0x" .. color:sub(3, 4)) / 255, + tonumber("0x" .. color:sub(5, 6)) / 255, 1 + end + if strlen == 8 then + return tonumber("0x" .. color:sub(1, 2)) / 255, + tonumber("0x" .. color:sub(3, 4)) / 255, + tonumber("0x" .. color:sub(5, 6)) / 255, + tonumber("0x" .. color:sub(7, 8)) / 255 + end +end + +-- Converts the given hex color to hsv +local function hex2hsv(color) + local r, g, b = hex2rgb(color) + local C_max = max(r, g, b) + local C_min = min(r, g, b) + local delta = C_max - C_min + local H, S, V + if delta == 0 then + H = 0 + elseif C_max == r then + H = 60 * (((g - b) / delta) % 6) + elseif C_max == g then + H = 60 * (((b - r) / delta) + 2) + elseif C_max == b then + H = 60 * (((r - g) / delta) + 4) + end + if C_max == 0 then + S = 0 + else + S = delta / C_max + end + V = C_max + return H, S * 100, V * 100 +end + +-- Converts the given hsv color to hex +local function hsv2hex(H, S, V) + S = S / 100 + V = V / 100 + if H > 360 then H = 360 end + if H < 0 then H = 0 end + local C = V * S + local X = C * (1 - math.abs(((H / 60) % 2) - 1)) + local m = V - C + local r_, g_, b_ = 0, 0, 0 + if H >= 0 and H < 60 then + r_, g_, b_ = C, X, 0 + elseif H >= 60 and H < 120 then + r_, g_, b_ = X, C, 0 + elseif H >= 120 and H < 180 then + r_, g_, b_ = 0, C, X + elseif H >= 180 and H < 240 then + r_, g_, b_ = 0, X, C + elseif H >= 240 and H < 300 then + r_, g_, b_ = X, 0, C + elseif H >= 300 and H < 360 then + r_, g_, b_ = C, 0, X + end + local r, g, b = (r_ + m) * 255, (g_ + m) * 255, (b_ + m) * 255 + return ("#%02x%02x%02x"):format(floor(r), floor(g), floor(b)) +end + +-- Calculates the relative luminance of the given color +local function relative_luminance(color) + local r, g, b = hex2rgb(color) + local function from_sRGB(u) + return u <= 0.0031308 and 25 * u / 323 or + pow(((200 * u + 11) / 211), 12 / 5) + end + return 0.2126 * from_sRGB(r) + 0.7152 * from_sRGB(g) + 0.0722 * from_sRGB(b) +end + +-- Calculates the contrast ratio between the two given colors +local function contrast_ratio(fg, bg) + return (relative_luminance(fg) + 0.05) / (relative_luminance(bg) + 0.05) +end + +-- Returns true if the contrast between the two given colors is suitable +local function is_contrast_acceptable(fg, bg) + return contrast_ratio(fg, bg) >= 7 and true +end + +-- Returns a bright-ish, saturated-ish, color of random hue +local function rand_hex(lb_angle, ub_angle) + return hsv2hex(random(lb_angle or 0, ub_angle or 360), 70, 90) +end + +-- Rotates the hue of the given hex color by the specified angle (in degrees) +local function rotate_hue(color, angle) + local H, S, V = hex2hsv(color) + angle = clip(angle or 0, 0, 360) + H = (H + angle) % 360 + return hsv2hex(H, S, V) +end + +-- Lightens a given hex color by the specified amount +local function lighten(color, amount) + local r, g, b + r, g, b = hex2rgb(color) + r = 255 * r + g = 255 * g + b = 255 * b + r = r + floor(2.55 * amount) + g = g + floor(2.55 * amount) + b = b + floor(2.55 * amount) + r = r > 255 and 255 or r + g = g > 255 and 255 or g + b = b > 255 and 255 or b + return ("#%02x%02x%02x"):format(r, g, b) +end + +-- Darkens a given hex color by the specified amount +local function darken(color, amount) + local r, g, b + r, g, b = hex2rgb(color) + r = 255 * r + g = 255 * g + b = 255 * b + r = max(0, r - floor(r * (amount / 100))) + g = max(0, g - floor(g * (amount / 100))) + b = max(0, b - floor(b * (amount / 100))) + return ("#%02x%02x%02x"):format(r, g, b) +end + +return { + clip = clip, + hex2rgb = hex2rgb, + hex2hsv = hex2hsv, + hsv2hex = hsv2hex, + relative_luminance = relative_luminance, + contrast_ratio = contrast_ratio, + is_contrast_acceptable = is_contrast_acceptable, + rand_hex = rand_hex, + rotate_hue = rotate_hue, + lighten = lighten, + darken = darken, +} diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..238493a --- /dev/null +++ b/init.lua @@ -0,0 +1,853 @@ +---- +-- ============================================================ +-- > Awesome WM LIBS +local awful = require("awful") +local atooltip = awful.tooltip +local wibox = require("wibox") +-- Widgets +local imagebox = wibox.widget.imagebox +local textbox = wibox.widget.textbox +-- Layouts +local wlayout = wibox.layout +local wlayout_align_horizontal = wlayout.align.horizontal +local wlayout_fixed_horizontal = wlayout.fixed.horizontal +-- Containers +local wcontainer = wibox.container +local wcontainer_background = wcontainer.background +local wcontainer_place = wcontainer.place +local wcontainer_constraint = wcontainer.constraint +-- Gears +local gsurface = require("gears.surface") +local gtimer = require("gears.timer") +local gtimer_weak_start_new = gtimer.weak_start_new +-- > MATH LIBS +local math = math +local max = math.max +local abs = math.abs +local rad = math.rad +local floor = math.floor +-- > LGI LIBS +local lgi = require("lgi") +local cairo = lgi.cairo +local gdk = lgi.Gdk +local pixbuf_get_from_window = gdk.pixbuf_get_from_window +local get_default_root_window = gdk.get_default_root_window +local pixbuf_get_from_surface = gdk.pixbuf_get_from_surface +-- > NICE LIBS +-- Colors +local colors = require("nice.colors") +local color_lighten = colors.lighten +local color_darken = colors.darken +local is_contrast_acceptable = colors.is_contrast_acceptable +local relative_luminance = colors.relative_luminance +-- Shapes +local shapes = require("nice.shapes") +local create_corner_top_left = shapes.create_corner_top_left +local create_edge_top_middle = shapes.create_edge_top_middle +local create_edge_left = shapes.create_edge_left +local gradient = shapes.duotone_gradient_vertical +gdk.init({}) + +-- => Local settings +-- ============================================================ +local bottom_edge_height = 3 +local double_click_jitter_tolerance = 4 +local double_click_time_window_ms = 250 +local stroke_inner_bottom_lighten_mul = 0.4 +local stroke_inner_sides_lighten_mul = 0.4 +local stroke_outer_top_darken_mul = 0.7 +local title_color_dark = "#242424" +local title_color_light = "#fefefa" +local title_unfocused_opacity = 0.7 +local titlebar_gradient_c1_lighten = 1 +local titlebar_gradient_c2_offset = 0.5 + +local function rel_lighten(lum) return lum * 90 + 10 end +local function rel_darken(lum) return -(lum * 70) + 100 end +-- ------------------------------------------------------------ + +local nice = {} + +-- => Defaults +-- ============================================================ +local _private = {} +_private.max_width = 0 +_private.max_height = 0 + +-- Titlebar +_private.titlebar_color = "#1E1E24" +_private.titlebar_height = 38 +_private.titlebar_radius = 9 +_private.titlebar_margin_left = 0 +_private.titlebar_margin_right = 0 +_private.titlebar_font = "Inter Regular 10" +_private.titlebar_items = { + left = {"close", "minimize", "maximize"}, + middle = {"title"}, + right = {"sticky", "ontop", "floating"}, +} +_private.context_menu_theme = { + bg_normal = "#5e6472", + fg_normal = "#fefefa", + bg_focus = "#aed9e0", + fg_focus = "#242424", + border_color = "#00000000", + border_width = 0, + height = 35, + width = 250, + font = "Inter Regular 10", +} +_private.window_shade_enabled = true +-- Button +_private.button_margin_horizontal = 5 +_private.button_margin_top = 2 +_private.button_size = 16 +_private.tooltips_enabled = true +_private.tooltip_messages = { + close = "close", + minimize = "minimize", + floating_active = "enable tiling mode", + floating_inactive = "enable floating mode", + maximize_active = "unmaximize", + maximize_inactive = "maximize", + ontop_active = "don't keep above other windows", + ontop_inactive = "keep above other windows", + sticky_active = "disable sticky mode", + sticky_inactive = "enable sticky mode", +} +_private.close_color = "#ee4266" +_private.minimize_color = "#ffb400" +_private.maximize_color = "#4CBB17" +_private.floating_color = "#f6a2ed" +_private.ontop_color = "#f6a2ed" +_private.sticky_color = "#f6a2ed" +-- ------------------------------------------------------------ + +-- => Saving and loading of color rules +-- ============================================================ +local table = table +local t = require("nice.table") +table.save = t.save +table.load = t.load + +-- Load the color rules or create an empty table if there aren't any +local gfilesys = require("gears.filesystem") +local config_dir = gfilesys.get_configuration_dir() +local color_rules_filename = "color_rules" +local color_rules_filepath = config_dir .. "/nice/" .. color_rules_filename +_private.color_rules = table.load(color_rules_filepath) or {} + +-- Saves the contents of _private.color_rules table to file +local function save_color_rules() + table.save(_private.color_rules, color_rules_filepath) +end + +-- Adds a color rule entry to the color_rules table for the given client and saves to file +local function set_color_rule(c, color) + _private.color_rules[c.instance] = color + save_color_rules() +end + +-- Fetches the color rule for the given client instance +local function get_color_rule(c) return _private.color_rules[c.instance] end +-- ------------------------------------------------------------ + +-- Returns the hex color for the pixel at the given coordinates on the screen +local function get_pixel_at(x, y) + local pixbuf = pixbuf_get_from_window(get_default_root_window(), x, y, 1, 1) + local bytes = pixbuf:get_pixels() + return "#" .. + bytes:gsub( + ".", function(c) return ("%02x"):format(c:byte()) end) +end + +-- Determines the dominant color of the client's top region +local function get_dominant_color(client) + local color + gsurface(client.content):write_to_png( + "/home/mutex/nice/" .. client.class .. "_" .. client.instance .. ".png") + local pb + local bytes + local tally = {} + local content = gsurface(client.content) + local cgeo = client:geometry() + local x_offset = 2 + local y_offset = 2 + local x_lim = floor(cgeo.width / 2) + for x_pos = 0, x_lim, 2 do + for y_pos = 0, 8, 1 do + pb = pixbuf_get_from_surface( + content, x_offset + x_pos, y_offset + y_pos, 1, 1) + bytes = pb:get_pixels() + color = "#" .. + bytes:gsub( + ".", + function(c) + return ("%02x"):format(c:byte()) + end) + if not tally[color] then + tally[color] = 1 + else + tally[color] = tally[color] + 1 + end + end + end + local mode + local mode_c = 0 + for kolor, kount in pairs(tally) do + if kount > mode_c then + mode_c = kount + mode = kolor + end + end + color = mode + set_color_rule(client, color) + return color +end + +-- Returns a color that is analogous to the last color returned +-- To make sure that the "randomly" generated colors look cohesive, only the first color is truly random, the rest are generated by offseting the hue by +33 degrees +local next_color = colors.rand_hex() +local function get_next_color() + local prev_color = next_color + next_color = colors.rotate_hue(prev_color, 33) + return prev_color +end + +-- Returns (or generates) a button image based on the given params +local function create_button_image(name, is_focused, event, is_on) + local focus_state = is_focused and "focused" or "unfocused" + local key_img + -- If it is a toggle button, then the key has an extra param + if is_on ~= nil then + local toggle_state = is_on and "on" or "off" + key_img = ("%s_%s_%s_%s"):format(name, toggle_state, focus_state, event) + else + key_img = ("%s_%s_%s"):format(name, focus_state, event) + end + -- If an image already exists, then we are done + if _private[key_img] then return _private[key_img] end + -- The color key just has _color at the end + local key_color = key_img .. "_color" + -- If the user hasn't provided a color, then we have to generate one + if not _private[key_color] then + local key_base_color = name .. "_color" + -- Maybe the user has at least provided a base color? If not we just pick a pesudo-random color + local base_color = _private[key_base_color] or get_next_color() + _private[key_base_color] = base_color + local button_color = base_color + local H = colors.hex2hsv(base_color) + -- Unfocused buttons are desaturated and darkened (except when they are being hovered over) + if not is_focused and event ~= "hover" then + button_color = colors.hsv2hex(H, 0, 50) + end + -- Then the color is lightened if the button is being hovered over, or darkened if it is being pressed, otherwise it is left as is + button_color = + (event == "hover") and colors.lighten(button_color, 25) or + (event == "press") and colors.darken(button_color, 25) or + button_color + -- Save the generate color because why not lol + _private[key_color] = button_color + end + local button_size = _private.button_size + -- If it is a toggle button, we create an outline instead of a filled shape if it is in off state + -- _private[key_img] = (is_on ~= nil and is_on == false) and + -- shapes.circle_outline( + -- _private[key_color], button_size, + -- _private.button_border_width) or + -- shapes.circle_filled( + -- _private[key_color], button_size) + _private[key_img] = shapes.circle_filled(_private[key_color], button_size) + return _private[key_img] +end + +-- Creates a titlebar button widget +local function create_titlebar_button(c, name, button_callback, property) + local button_img = imagebox(nil, false) + if _private.tooltips_enabled then + local tooltip = atooltip { + timer_function = function() + return _private.tooltip_messages[name .. + (property and + (c[property] and "_active" or "_inactive") or "")] + end, + delay_show = 0.5, + margins_leftright = 12, + margins_topbottom = 6, + timeout = 0.25, + align = "bottom_right", + } + tooltip:add_to_object(button_img) + end + local is_on, is_focused + local event = "normal" + local function update() + is_focused = c.active + -- If the button is for a property that can be toggled + if property then + is_on = c[property] + button_img.image = create_button_image( + name, is_focused, event, is_on) + else + button_img.image = create_button_image(name, is_focused, event) + end + end + -- Update the button when the client gains/loses focus + c:connect_signal("unfocus", update) + c:connect_signal("focus", update) + -- If the button is for a property that can be toggled, update it accordingly + if property then c:connect_signal("property::" .. property, update) end + -- Update the button on mouse hover/leave + button_img:connect_signal( + "mouse::enter", function() + event = "hover" + update() + end) + button_img:connect_signal( + "mouse::leave", function() + event = "normal" + update() + end) + -- The button is updated on both click and release, but the call back is executed on release + button_img.buttons = awful.button( + {}, awful.button.names.LEFT, function() + event = "press" + update() + end, function() + if button_callback then + event = "normal" + button_callback() + else + event = "hover" + end + update() + end) + local margin = _private.button_spacing + button_img.id = "button_image" + update() + return wibox.widget { + widget = wcontainer_place, + { + widget = wcontainer.margin, + top = _private.button_margin_top or _private.button_margin_vertical or + _private.button_margin, + bottom = _private.button_margin_bottom or + _private.button_margin_vertical or _private.button_margin, + left = _private.button_margin_left or + _private.button_margin_horizontal or _private.button_margin, + right = _private.button_margin_right or + _private.button_margin_horizontal or _private.button_margin, + { + button_img, + widget = wcontainer_constraint, + height = _private.button_size, + width = _private.button_size, + strategy = "exact", + }, + }, + } +end + +-- Creates a client title widget +local function create_titlebar_title(c) + local client_color = c._nice_base_color + local shade_enabled = _private.window_shade_enabled + -- Add functionality for double click to (un)maximize, and single click and hold to move + local clicks = 0 + local tolerance = double_click_jitter_tolerance + local buttons = { + awful.button( + {}, awful.button.names.LEFT, function() + local cx, cy = _G.mouse.coords().x, _G.mouse.coords().y + local delta = double_click_time_window_ms / 1000 + clicks = clicks + 1 + if clicks == 2 then + local nx, ny = _G.mouse.coords().x, _G.mouse.coords().y + -- The second click is only counted as a double click if it is within the neighborhood of the first click's position, and occurs within the set time window + if abs(cx - nx) <= tolerance and abs(cy - ny) <= tolerance then + if shade_enabled then + _private.shade_roll_down(c) + end + c.maximized = not c.maximized + end + else + if shade_enabled then + _private.shade_roll_down(c) + end + c:activate{context = "titlebar", action = "mouse_move"} + end + -- Start a timer to clear the click count + gtimer_weak_start_new( + delta, function() clicks = 0 end) + end), + awful.button( + {}, awful.button.names.RIGHT, function() + + local menu_items = {} + local function add_item(text, callback) + -- right_click_menu(right_click_menu, {text, callback}) + menu_items[#menu_items + 1] = {text, callback} + end + -- TODO: Add client control options as menu enteries for options that haven't had their buttons added + add_item( + "Redo Window Decorations", function() + c._nice_base_color = get_dominant_color(c) + set_color_rule(c, c._nice_base_color) + _private.add_window_decorations(c) + end) + local picked_color + add_item( + "Manually Pick Color", function() + _G.mousegrabber.run( + function(m) + if m.buttons[1] then + c._nice_base_color = get_pixel_at(m.x, m.y) + set_color_rule(c, c._nice_base_color) + _private.add_window_decorations(c) + return false + end + return true + end, "crosshair") + end) + -- if c._nice_window_shade then + -- local win_shade = c._nice_window_shade + -- add_item( + -- not win_shade.visible and "Roll Up" or "Roll Down", + -- function() + -- _private.shade_toggle(c) + -- end) + -- end + add_item("Nevermind...", function() end) + if c._nice_right_click_menu then + c._nice_right_click_menu:hide() + end + c._nice_right_click_menu = + awful.menu { + items = menu_items, + theme = _private.context_menu_theme, + } + c._nice_right_click_menu:show() + end), + } + + if _private.window_shade_enabled then + buttons[#buttons + 1] = awful.button( + {}, awful.button.names.SCROLL_UP, + function() + _private.shade_roll_up(c) + end) + buttons[#buttons + 1] = awful.button( + {}, awful.button.names.SCROLL_DOWN, + function() + _private.shade_roll_down(c) + end) + end + + local title_widget = wibox.widget { + align = "center", + buttons = buttons, + ellipsize = "middle", + font = _private.titlebar_font, + opacity = c.active and 1 or title_unfocused_opacity, + valign = "center", + widget = textbox, + + } + local function update() + local text_color = is_contrast_acceptable( + title_color_light, client_color) and + title_color_light or title_color_dark + + title_widget.markup = + ("%s"):format( + text_color, _private.titlebar_font or "IBM Plex Sans 11", c.name) + end + c:connect_signal("property::name", update) + -- c:connect_signal("property::_nice_color", update) + c:connect_signal( + "unfocus", function() + title_widget.opacity = title_unfocused_opacity + end) + c:connect_signal("focus", function() title_widget.opacity = 1 end) + update() + return {title_widget, widget = wcontainer.margin, left = 4, right = 4} +end + +-- Creates titlebar items for a given group of item names +local function create_titlebar_items(c, group) + if not group then return nil end + local titlebar_group_items = wibox.widget { + layout = wlayout_fixed_horizontal, + } + local item + for _, name in ipairs(group) do + if name == "close" then + item = create_titlebar_button( + c, name, function() c:kill() end) + elseif name == "maximize" then + item = create_titlebar_button( + c, name, function() + c.maximized = not c.maximized + end, "maximized") + elseif name == "minimize" then + item = create_titlebar_button( + c, name, function() c.minimized = true end) + elseif name == "ontop" then + item = create_titlebar_button( + c, name, function() c.ontop = not c.ontop end, "ontop") + elseif name == "floating" then + item = create_titlebar_button( + c, name, function() + c.floating = not c.floating + if c.floating then c.maximized = false end + end, "floating") + elseif name == "sticky" then + item = create_titlebar_button( + c, name, function() + c.sticky = not c.sticky + return c.sticky + end, "sticky") + elseif name == "title" then + item = create_titlebar_title(c) + end + if #group == 1 then return item end + titlebar_group_items:add(item) + end + return titlebar_group_items +end +-- ------------------------------------------------------------ + +-- Adds a window shade to the given client +local function add_window_shade(c, src_top, src_bottom) + local geo = c:geometry() + local w = wibox() + w.width = geo.width + w.background = "transparent" + w.x = geo.x + w.y = geo.y + w.height = _private.titlebar_height + 3 + w.ontop = true + w.visible = false + w.shape= shapes.rounded_rect { + tl = _private.titlebar_radius+1, + tr = _private.titlebar_radius+1, + bl = 4, + br = 4, + } + w.widget = wibox.widget { + { + src_top, + src_bottom, + layout = wlayout.fixed.vertical, + }, + widget = wcontainer_background, + bg = "transparent", + } + c:connect_signal( + "request::unmanage", function() + if c._nice_window_shade then + c._nice_window_shade.visible = false + c._nice_window_shade = nil + end + collectgarbage() + collectgarbage() + end) + c._nice_window_shade = w +end + +-- Shows the window contents +function _private.shade_roll_down(c) + c.minimized = false + c._nice_window_shade.visible = false +end + +-- Hides the window contents +function _private.shade_roll_up(c) + local w = c._nice_window_shade + local geo = c:geometry() + w.x = geo.x + w.y = geo.y + w.width = geo.width + c.minimized = true + w.visible = true + w.ontop = true +end + +-- Toggles the window shade state +function _private.shade_toggle(c) + c.minimized = not c.minimized + c._nice_window_shade.visible = c.minimized +end + +-- Puts all the pieces together and decorates the given client +function _private.add_window_decorations(c) + local client_color = c._nice_base_color + -- Closures to avoid repitition + local lighten = function(amount) + return color_lighten(client_color, amount) + end + local darken = + function(amount) return color_darken(client_color, amount) end + -- > Color computations + local luminance = relative_luminance(client_color) + local lighten_amount = rel_lighten(luminance) + local darken_amount = rel_darken(luminance) + -- Inner strokes + local stroke_color_inner_top = lighten(lighten_amount) + local stroke_color_inner_sides = lighten( + + + lighten_amount * + stroke_inner_sides_lighten_mul) + local stroke_color_inner_bottom = lighten( + + + lighten_amount * + stroke_inner_bottom_lighten_mul) + -- Outer strokes + local stroke_color_outer_top = darken( + + darken_amount * + stroke_outer_top_darken_mul) + local stroke_color_outer_sides = darken(darken_amount) + local stroke_color_outer_bottom = darken(darken_amount) + local titlebar_height = _private.titlebar_height + local background_fill_top = gradient( + + lighten(titlebar_gradient_c1_lighten), + client_color, titlebar_height, 0, + titlebar_gradient_c2_offset) + -- The top left corner of the titlebar + local corner_top_left_img = create_corner_top_left { + background_source = background_fill_top, + color = client_color, + height = titlebar_height, + radius = _private.titlebar_radius, + stroke_offset_inner = 1.5, + stroke_width_inner = 1, + stroke_offset_outer = 0.5, + stroke_width_outer = 1, + stroke_source_inner = gradient( + stroke_color_inner_top, stroke_color_inner_sides, titlebar_height), + stroke_source_outer = gradient( + stroke_color_outer_top, stroke_color_outer_sides, titlebar_height), + } + -- The top right corner of the titlebar + local corner_top_right_img = shapes.flip(corner_top_left_img, "horizontal") + + -- The middle part of the titlebar + local top_edge = create_edge_top_middle { + background_source = background_fill_top, + color = client_color, + height = titlebar_height, + stroke_color_inner = stroke_color_inner_top, + stroke_color_outer = stroke_color_outer_top, + stroke_offset_inner = 1.25, + stroke_offset_outer = 0.5, + stroke_width_inner = 2, + stroke_width_outer = 1, + width = _private.max_width, + } + -- Create the titlebar + local titlebar = awful.titlebar( + c, {size = titlebar_height, bg = "transparent"}) + -- Arrange the graphics + titlebar.widget = { + imagebox(corner_top_left_img, false), + { + { + { + create_titlebar_items(c, _private.titlebar_items.left), + widget = wcontainer.margin, + left = _private.titlebar_margin_left, + }, + create_titlebar_items(c, _private.titlebar_items.middle), + { + create_titlebar_items(c, _private.titlebar_items.right), + widget = wcontainer.margin, + right = _private.titlebar_margin_right, + }, + layout = wlayout_align_horizontal, + }, + widget = wcontainer_background, + bgimage = top_edge, + }, + imagebox(corner_top_right_img, false), + layout = wlayout_align_horizontal, + } + -- The left side border + local left_border_img = create_edge_left { + client_color = client_color, + height = _private.max_height, + stroke_offset_outer = 0.5, + stroke_width_outer = 1, + stroke_color_outer = stroke_color_outer_sides, + stroke_offset_inner = 1.5, + stroke_width_inner = 1.5, + inner_stroke_color = stroke_color_inner_sides, + } + -- The right side border + local right_border_img = shapes.flip(left_border_img, "horizontal") + local left_side_border = awful.titlebar( + c, { + position = "left", + size = 2, + bg = client_color, + widget = wcontainer_background, + }) + left_side_border:setup{ + widget = wcontainer_background, + bgimage = left_border_img, + buttons = { + awful.button( + {}, 1, function() + c:activate{context = "mouse_click", action = "mouse_resize"} + end), + }, + } + local right_side_border = awful.titlebar( + c, { + position = "right", + size = 2, + bg = client_color, + widget = wcontainer_background, + }) + right_side_border:setup{ + widget = wcontainer_background, + bgimage = right_border_img, + buttons = { + awful.button( + {}, 1, function() + c:activate{context = "mouse_click", action = "mouse_resize"} + end), + }, + } + local corner_bottom_left_img = shapes.flip( + + create_corner_top_left { + color = client_color, + radius = bottom_edge_height, + height = bottom_edge_height, + background_source = background_fill_top, + stroke_offset_inner = 1.5, + stroke_offset_outer = 0.5, + stroke_source_outer = gradient( + stroke_color_outer_bottom, stroke_color_outer_sides, + bottom_edge_height, 0, 0.25), + stroke_source_inner = gradient( + stroke_color_inner_bottom, stroke_color_inner_sides, + bottom_edge_height), + stroke_width_inner = 1.5, + stroke_width_outer = 2, + }, "vertical") + local corner_bottom_right_img = shapes.flip( + corner_bottom_left_img, "horizontal") + local bottom_edge = shapes.flip( + create_edge_top_middle { + color = client_color, + height = bottom_edge_height, + background_source = background_fill_top, + stroke_color_inner = stroke_color_inner_bottom, + stroke_color_outer = stroke_color_outer_bottom, + stroke_offset_inner = 1.25, + stroke_offset_outer = 0.5, + stroke_width_inner = 1, + stroke_width_outer = 1, + width = _private.max_width, + }, "vertical") + local bottom = awful.titlebar( + c, { + size = bottom_edge_height, + bg = "transparent", + position = "bottom", + }) + bottom:setup{ + imagebox(corner_bottom_left_img, false), + {widget = wcontainer_background, bgimage = bottom_edge}, + imagebox(corner_bottom_right_img, false), + layout = wlayout_align_horizontal, + } + if _private.window_shade_enabled then + add_window_shade(c, titlebar.widget, bottom.widget) + end + -- Clean up + collectgarbage() + collectgarbage() +end + +local function update_max_screen_dims() + local max_height, max_width = 0, 0 + for s in _G.screen do + max_height = max(max_height, s.geometry.height) + max_width = max(max_width, s.geometry.width) + end + _private.max_height = max_height * 1.5 + _private.max_width = max_width * 1.5 +end +--[[ + +]] +function nice.initialize(args) + update_max_screen_dims() + _G.screen.connect_signal("list", update_max_screen_dims) + local crush = require("gears.table").crush + local table_args = { + titlebar_items = true, + context_menu_theme = true, + tooltip_messages = true, + } + if args then + for prop, value in pairs(args) do + if table_args[prop] == true then + crush(_private[prop], value) + elseif prop == "titlebar_radius" then + value = max(3, value) + _private[prop] = value + else + _private[prop] = value + end + end + end + + _G.client.connect_signal( + "request::titlebars", function(c) + -- Callback + c._cb_add_window_decorations = + function() + gtimer_weak_start_new( + 0.25, function() + c._nice_base_color = get_dominant_color(c) + set_color_rule(c, c._nice_base_color) + _private.add_window_decorations(c) + -- table.save(_private, config_dir .. "/nice/private") + c:disconnect_signal( + "request::activate", + c._cb_add_window_decorations) + end) + end -- _cb_add_window_decorations + -- Check if a color rule already exists... + local base_color = get_color_rule(c) + if base_color then + -- If so, use that color rule + c._nice_base_color = base_color + _private.add_window_decorations(c) + else + -- Otherwise use the default titlebar temporarily + c._nice_base_color = _private.titlebar_color + _private.add_window_decorations(c) + -- Connect a signal to determine the client color and then re-decorate it + c:connect_signal( + "request::activate", c._cb_add_window_decorations) + end + -- Shape the client, unnecessary if there are no shadows under the clients. + c.shape = shapes.rounded_rect { + tl = _private.titlebar_radius, + tr = _private.titlebar_radius, + bl = 4, + br = 4, + } + end) +end + +return setmetatable( + nice, {__call = function(_, ...) return nice.initialize(...) end}) diff --git a/preview.png b/preview.png new file mode 100644 index 0000000..f873877 Binary files /dev/null and b/preview.png differ diff --git a/shapes.lua b/shapes.lua new file mode 100644 index 0000000..a273bc2 --- /dev/null +++ b/shapes.lua @@ -0,0 +1,212 @@ +-- => Shapes +-- Provides utility functions for handling cairo shapes and geometry +-- ============================================================ +-- +local lgi = require("lgi") +local colors = require("nice.colors") +local hex2rgb = colors.hex2rgb +local darken = colors.darken +local cairo = lgi.cairo +local math = math +local rad = math.rad +local floor = math.floor + +-- Returns a shape function for a rounded rectangle with independently configurable corner radii +local function rounded_rect(args) + local r1 = args.tl or 0 + local r2 = args.bl or 0 + local r3 = args.br or 0 + local r4 = args.tr or 0 + return function(cr, width, height) + cr:new_sub_path() + cr:arc(width - r1, r1, r1, rad(-90), rad(0)) + cr:arc(width - r2, height - r2, r2, rad(0), rad(90)) + cr:arc(r3, height - r3, r3, rad(90), rad(180)) + cr:arc(r4, r4, r4, rad(180), rad(270)) + cr:close_path() + end +end + +-- Returns a circle of the specified size filled with the specified color +local function circle_filled(color, size) + color = color or "#fefefa" + local surface = cairo.ImageSurface.create("ARGB32", size, size) + local cr = cairo.Context.create(surface) + cr:arc(size / 2, size / 2, size / 2, rad(0), rad(360)) + cr:set_source_rgba(hex2rgb(color)) + cr.antialias = cairo.Antialias.BEST + cr:fill() + -- cr:arc( + -- size / 2, size / 2, size / 2 - 0.5, rad(135), rad(270)) + -- cr:set_source_rgba(hex2rgb(darken(color, 25))) + -- cr.line_width = 1 + -- cr:stroke() + collectgarbage() + collectgarbage() + return surface +end + +-- Returns a vertical gradient pattern going from cololr_1 -> color_2 +local function duotone_gradient_vertical(color_1, color_2, height, offset_1, + offset_2) + local fill_pattern = cairo.Pattern.create_linear(0, 0, 0, height) + local r, g, b, a + r, g, b, a = hex2rgb(color_1) + fill_pattern:add_color_stop_rgba(offset_1 or 0, r, g, b, a) + r, g, b, a = hex2rgb(color_2) + fill_pattern:add_color_stop_rgba(offset_2 or 1, r, g, b, a) + return fill_pattern +end + +-- Flips the given surface around the specified axis +local function flip(surface, axis) + local width = surface:get_width() + local height = surface:get_height() + local flipped = cairo.ImageSurface.create("ARGB32", width, height) + local cr = cairo.Context.create(flipped) + local source_pattern = cairo.Pattern.create_for_surface(surface) + if axis == "horizontal" then + source_pattern.matrix = cairo.Matrix {xx = -1, yy = 1, x0 = width} + elseif axis == "vertical" then + source_pattern.matrix = cairo.Matrix {xx = 1, yy = -1, y0 = height} + elseif axis == "both" then + source_pattern.matrix = cairo.Matrix { + xx = -1, + yy = -1, + x0 = width, + y0 = height, + } + end + cr.source = source_pattern + cr:rectangle(0, 0, width, height) + cr:paint() + collectgarbage() + collectgarbage() + return flipped +end + +-- Draws the left corner of the titlebar +local function create_corner_top_left(args) + local radius = args.radius + local height = args.height + local surface = cairo.ImageSurface.create("ARGB32", radius, height) + local cr = cairo.Context.create(surface) + -- Create the corner shape and fill it with a gradient + local radius_offset = 1 -- To soften the corner + cr:move_to(0, height) + cr:line_to(0, radius - radius_offset) + cr:arc( + radius + radius_offset, radius + radius_offset, radius, rad(180), + rad(270)) + cr:line_to(radius, height) + cr:close_path() + cr.source = args.background_source + cr.antialias = cairo.Antialias.BEST + cr:fill() + -- Next add the subtle 3D look + local function add_stroke(nargs) + local arc_radius = nargs.radius + local offset_x = nargs.offset_x + local offset_y = nargs.offset_y + cr:new_sub_path() + cr:move_to(offset_x, height) + cr:line_to(offset_x, arc_radius + offset_y) + cr:arc( + arc_radius + offset_x, arc_radius + offset_y, arc_radius, rad(180), + rad(270)) + cr.source = nargs.source + cr.line_width = nargs.width + cr.antialias = cairo.Antialias.BEST + cr:stroke() + end + -- Outer dark stroke + add_stroke { + offset_x = args.stroke_offset_outer, + offset_y = args.stroke_offset_outer, + radius = radius + 0.5, + source = args.stroke_source_outer, + width = args.stroke_width_outer, + } + -- Inner light stroke + add_stroke { + offset_x = args.stroke_offset_inner, + offset_y = args.stroke_offset_inner, + radius = radius, + width = args.stroke_width_inner, + source = args.stroke_source_inner, + } + collectgarbage() + collectgarbage() + return surface +end + +-- Draws the middle of the titlebar +local function create_edge_top_middle(args) + local client_color = args.color + local height = args.height + local width = args.width + local surface = cairo.ImageSurface.create("ARGB32", width, height) + local cr = cairo.Context.create(surface) + -- Create the background shape and fill it with a gradient + cr:rectangle(0, 0, width, height) + cr.source = args.background_source + cr:fill() + -- Then add the light and dark strokes for that 3D look + local function add_stroke(stroke_width, stroke_offset, stroke_color) + cr:new_sub_path() + cr:move_to(0, stroke_offset) + cr:line_to(width, stroke_offset) + cr.line_width = stroke_width + cr:set_source_rgb(hex2rgb(stroke_color)) + cr:stroke() + end + -- Inner light stroke + add_stroke( + args.stroke_width_inner, args.stroke_offset_inner, + args.stroke_color_inner) + -- Outer dark stroke + add_stroke( + args.stroke_width_outer, args.stroke_offset_outer, + args.stroke_color_outer) + collectgarbage() + collectgarbage() + return surface +end + +local function create_edge_left(args) + local height = args.height + local width = 2 + -- height = height or 1080 + local surface = cairo.ImageSurface.create("ARGB32", width, height) + local cr = cairo.Context.create(surface) + cr:rectangle(0, 0, 2, args.height) + cr:set_source_rgb(hex2rgb(args.client_color)) + cr:fill() + -- Inner light stroke + cr:new_sub_path() + cr:move_to(args.stroke_offset_inner, 0) -- 1/5 + cr:line_to(args.stroke_offset_inner, height) + cr.line_width = args.stroke_width_inner -- 1.5 + cr:set_source_rgb(hex2rgb(args.inner_stroke_color)) + cr:stroke() + -- Outer dark stroke + cr:new_sub_path() + cr:move_to(args.stroke_offset_outer, 0) + cr:line_to(args.stroke_offset_outer, height) + cr.line_width = args.stroke_width_outer -- 1 + cr:set_source_rgb(hex2rgb(args.stroke_color_outer)) + cr:stroke() + collectgarbage() + collectgarbage() + return surface +end + +return { + rounded_rect = rounded_rect, + circle_filled = circle_filled, + duotone_gradient_vertical = duotone_gradient_vertical, + flip = flip, + create_corner_top_left = create_corner_top_left, + create_edge_top_middle = create_edge_top_middle, + create_edge_left = create_edge_left, +} diff --git a/table.lua b/table.lua new file mode 100644 index 0000000..c998b67 --- /dev/null +++ b/table.lua @@ -0,0 +1,100 @@ +--[[ + Courtesy of: http://lua-users.org/wiki/SaveTableToFile +]] local function exportstring(s) return string.format("%q", s) end + +-- // The Save Function +local function save(tbl, filename) + local charS, charE = " ", "\n" + local file, err = io.open(filename, "wb") + if err then return err end + + -- Initialize variables for save procedure + local tables, lookup = {tbl}, {[tbl] = 1} + file:write("return {" .. charE) + + for idx, t in ipairs(tables) do + file:write("-- Table: {" .. idx .. "}" .. charE) + file:write("{" .. charE) + local thandled = {} + + for i, v in ipairs(t) do + thandled[i] = true + local stype = type(v) + -- only handle value + if stype == "table" then + if not lookup[v] then + table.insert(tables, v) + lookup[v] = #tables + end + file:write(charS .. "{" .. lookup[v] .. "}," .. charE) + elseif stype == "string" then + file:write(charS .. exportstring(v) .. "," .. charE) + elseif stype == "number" then + file:write(charS .. tostring(v) .. "," .. charE) + end + end + + for i, v in pairs(t) do + -- escape handled values + if (not thandled[i]) then + + local str = "" + local stype = type(i) + -- handle index + if stype == "table" then + if not lookup[i] then + table.insert(tables, i) + lookup[i] = #tables + end + str = charS .. "[{" .. lookup[i] .. "}]=" + elseif stype == "string" then + str = charS .. "[" .. exportstring(i) .. "]=" + elseif stype == "number" then + str = charS .. "[" .. tostring(i) .. "]=" + end + + if str ~= "" then + stype = type(v) + -- handle value + if stype == "table" then + if not lookup[v] then + table.insert(tables, v) + lookup[v] = #tables + end + file:write(str .. "{" .. lookup[v] .. "}," .. charE) + elseif stype == "string" then + file:write(str .. exportstring(v) .. "," .. charE) + elseif stype == "number" then + file:write(str .. tostring(v) .. "," .. charE) + end + end + end + end + file:write("}," .. charE) + end + file:write("}") + file:close() +end + +-- The Load Function +local function load(sfile) + local ftables, err = loadfile(sfile) + if err then return _, err end + local tables = ftables() + for idx = 1, #tables do + local tolinki = {} + for i, v in pairs(tables[idx]) do + if type(v) == "table" then tables[idx][i] = tables[v[1]] end + if type(i) == "table" and tables[i[1]] then + table.insert(tolinki, {i, tables[i[1]]}) + end + end + -- link indices + for _, v in ipairs(tolinki) do + tables[idx][v[2]], tables[idx][v[1]] = tables[idx][v[1]], nil + end + end + return tables[1] +end + +return {save = save, load = load}