-- A frameless window with an app-drawn title bar: drag it by the bar,
-- double-click the bar to maximize, resize it from any edge, and use the
-- minimize / maximize / close buttons. The search box accepts typed and
-- IME-composed text (the composition shows underlined until committed);
-- ctrl+v pastes. Files dropped on the window are listed.
local ui = require("@neyuki/ui")
local string = require("@neyuki/string")
local math = require("@neyuki/math")
local utf8 = require("@neyuki/utf8")
local win = ui.open({
title = "Window chrome",
width = 900,
height = 600,
decorations = false,
minWidth = 480,
minHeight = 320,
})
local BAR = 36 -- title bar height
local BUTTON = 46 -- caption button width
local EDGE = 6 -- resize border thickness
local query = "" -- committed text in the search box
local preedit = "" -- what the input method is composing
local editing = false
local hovered = nil -- the control under the pointer
local pressed = nil -- the control a button press started on
local cursor = "default"
local status = "drag the bar, resize from the edges, type in the box"
local dropped = {}
-- Which edge or corner of the window (x, y) is on, or nil.
function edgeAt(x: number, y: number, w: number, h: number): string?
if (win:isMaximized()) then return nil end
local ns = ""
local ew = ""
if (y < EDGE) then ns = "n" elseif (y >= h - EDGE) then ns = "s" end
if (x < EDGE) then ew = "w" elseif (x >= w - EDGE) then ew = "e" end
local direction = ns .. ew
if (direction == "") then return nil end
return direction
end
local EDGE_CURSORS = {
n = "nsResize", s = "nsResize", e = "ewResize", w = "ewResize",
ne = "neswResize", sw = "neswResize", nw = "nwseResize", se = "nwseResize",
}
-- The search box and the caption buttons, laid out for the current size.
function layout(w: number): {any}
return {
close = { x = w - BUTTON, y = 0, w = BUTTON, h = BAR },
max = { x = w - BUTTON * 2, y = 0, w = BUTTON, h = BAR },
min = { x = w - BUTTON * 3, y = 0, w = BUTTON, h = BAR },
search = { x = 24, y = BAR + 24, w = math.min(420, w - 48), h = 36 },
link = { x = 24, y = BAR + 84, w = 180, h = 28 },
}
end
function inside(r: {any}, x: number, y: number): boolean
return x >= r.x and x < r.x + r.w and y >= r.y and y < r.y + r.h
end
-- The control at (x, y): an edge, a button, the search box, the bar or nil.
function hitTest(x: number, y: number): string?
local w, h = win:size()
local edge = edgeAt(x, y, w, h)
if (edge != nil) then return "edge:" .. edge end
local boxes = layout(w)
for _, name in { "close", "max", "min", "search", "link" } do
if (inside(boxes[name], x, y)) then return name end
end
if (y < BAR) then return "bar" end
return nil
end
function setEditing(on: boolean)
editing = on
if (not on) then preedit = "" end
local w, _ = win:size()
local box = layout(w).search
-- The candidate window opens just below the caret.
local textWidth, _ = ui.measureText(query, { size = 16 })
win:setTextInput(on, { x = box.x + 10 + textWidth, y = box.y, w = 1, h = box.h })
end
function press(control: string?, event: any)
if (control == nil) then
if (editing) then setEditing(false) end
return
end
if (string.startswith(control, "edge:")) then
win:startResize(string.sub(control, 6))
elseif (control == "bar") then
if (event.clicks == 2) then
if (win:isMaximized()) then win:restore() else win:maximize() end
else
win:startDrag()
end
elseif (control == "search") then
setEditing(true)
else
pressed = control
end
end
-- Caption buttons act on release over the same button, like native ones.
function release(control: string?)
if (pressed != nil and pressed == control) then
if (control == "close") then
win:close()
elseif (control == "max") then
if (win:isMaximized()) then win:restore() else win:maximize() end
elseif (control == "min") then
win:minimize()
elseif (control == "link") then
ui.openUrl("https://neyuki.cc/")
end
end
pressed = nil
end
function updateCursor()
local wanted = "default"
if (hovered != nil) then
if (string.startswith(hovered, "edge:")) then
wanted = EDGE_CURSORS[string.sub(hovered, 6)]
elseif (hovered == "search") then
wanted = "text"
elseif (hovered != "bar") then
wanted = "pointer"
end
end
if (wanted != cursor) then
cursor = wanted
win:setCursor(cursor)
end
end
function draw()
local w, h = win:size()
local boxes = layout(w)
win:clear("#1e1e2e")
-- Title bar and caption buttons.
win:fillRect(0, 0, w, BAR, "#181825")
win:text(14, 9, "Window chrome", { size = 14, color = "#cdd6f4" })
for _, name in { "min", "max", "close" } do
local r = boxes[name]
if (hovered == name) then
win:fillRect(r.x, r.y, r.w, r.h, name == "close" and "#e64553" or "#313244")
end
end
local cx, cy = boxes.min.x + BUTTON / 2, BAR / 2
win:line(cx - 5, cy, cx + 5, cy, "#cdd6f4")
cx = boxes.max.x + BUTTON / 2
if (win:isMaximized()) then
win:strokeRect(cx - 3, cy - 6, 8, 8, "#cdd6f4")
win:fillRect(cx - 6, cy - 3, 8, 8, hovered == "max" and "#313244" or "#181825")
win:strokeRect(cx - 6, cy - 3, 8, 8, "#cdd6f4")
else
win:strokeRect(cx - 5, cy - 5, 10, 10, "#cdd6f4")
end
cx = boxes.close.x + BUTTON / 2
win:line(cx - 5, cy - 5, cx + 5, cy + 5, "#cdd6f4")
win:line(cx - 5, cy + 5, cx + 5, cy - 5, "#cdd6f4")
-- The search box: committed text, then the composition underlined.
local box = boxes.search
win:fillRect(box.x, box.y, box.w, box.h, "#313244", { radius = 6 })
win:strokeRect(box.x, box.y, box.w, box.h, editing and "#89b4fa" or "#45475a", { radius = 6, width = 1.5 })
local x = box.x + 10
local y = box.y + 8
if (query == "" and preedit == "" and not editing) then
win:text(x, y, "Search...", { size = 16, color = "#6c7086" })
else
x += win:text(x, y, query, { size = 16, color = "#cdd6f4" })
if (preedit != "") then
local pw = win:text(x, y, preedit, { size = 16, color = "#f9e2af" })
win:line(x, y + 21, x + pw, y + 21, "#f9e2af")
x += pw
end
if (editing) then win:line(x + 1, y, x + 1, y + 20, "#cdd6f4", { width = 1.5 }) end
end
local link = boxes.link
win:text(link.x, link.y + 4, "Open the Neyuki website", {
size = 14, color = hovered == "link" and "#b4befe" or "#89b4fa",
})
win:text(24, BAR + 132, status, { size = 14, color = "#a6adc8" })
for i, path in dropped do
win:text(24, BAR + 150 + i * 20, "dropped: " .. path, { size = 13, color = "#a6adc8" })
end
-- A hairline border so the frameless window stands out from what is behind.
win:strokeRect(0.5, 0.5, w - 1, h - 1, "#45475a")
win:present()
end
ui.run(function(events: {Event}, _dt: number)
for _, event in events do
local kind = event.kind
if (kind == "mousemove") then
hovered = hitTest(event.x, event.y)
elseif (kind == "mouseleave") then
hovered = nil
elseif (kind == "mousedown" and event.button == "left") then
press(hitTest(event.x, event.y), event)
elseif (kind == "mouseup" and event.button == "left") then
release(hitTest(event.x, event.y))
elseif (kind == "textinput" and editing) then
query = query .. event.text
preedit = ""
setEditing(true) -- move the candidate window along
elseif (kind == "ime") then
if (event.phase == "preedit") then preedit = event.text else preedit = "" end
elseif (kind == "keydown" and editing) then
if (event.key == "backspace" and preedit == "" and query != "") then
query = utf8.sub(query, 1, -2)
elseif (event.key == "v" and (event.mods.ctrl or event.mods.meta)) then
query = query .. (ui.clipboard.get() ?? "")
elseif (event.key == "escape" or event.key == "enter") then
setEditing(false)
end
elseif (kind == "maximize" or kind == "minimize" or kind == "restore") then
status = "window: " .. kind
elseif (kind == "dpichange") then
status = "scale factor: " .. tostring(event.scale)
elseif (kind == "dropfile") then
dropped[#dropped + 1] = event.path
end
end
if (not win:isOpen()) then return end
updateCursor()
draw()
end)