add calculator plugin (#104)
* feat: add calculator plugin * add MIT license * fix(calculator): typo * fix(calculator): wrong PANEL_ID * remove LICENSE
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
# Calculator
|
||||
|
||||
A calculator for the Noctalia bar. It evaluates whole expressions with operator
|
||||
precedence, offers both a keypad and a typed input line in its panel, and keeps
|
||||
the last result visible in the bar.
|
||||
|
||||
## Plugin
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| ID | `yuuto/calculator` |
|
||||
| Entries | Bar widget: `bar`; panel: `panel` |
|
||||
|
||||
The `panel` entry owns the calculator model and publishes it through Noctalia's
|
||||
per-plugin state channel. The `bar` widget is a thin client: it reads that state
|
||||
and opens the panel.
|
||||
|
||||
## Usage
|
||||
|
||||
Add the `bar` bar widget to your bar. It shows a calculator glyph, plus the last
|
||||
result once there is one. Left click opens the panel, right click clears the
|
||||
calculator. On a vertical bar only the glyph is shown, with the current
|
||||
expression as its tooltip.
|
||||
|
||||
The panel evaluates as you type. The small line shows the expression, the large
|
||||
line the live result, and the copy button next to it puts the result on the
|
||||
clipboard.
|
||||
|
||||
Enter expressions either with the keypad or by typing into the input line, where
|
||||
Enter evaluates. Pressing `=` keeps the result in the input, so the next
|
||||
operator continues from it. The keypad uses plain text labels (`AC`, `+/-`,
|
||||
`DEL`, `*`, `/`) rather than symbol glyphs, so it renders with any bar font.
|
||||
|
||||
Open the panel over IPC:
|
||||
|
||||
```sh
|
||||
noctalia msg panel-toggle yuuto/calculator:panel
|
||||
```
|
||||
|
||||
### Expression syntax
|
||||
|
||||
Operators are `+`, `-`, `*`, `/` and `^`, with parentheses and the usual
|
||||
precedence; `^` is right associative. A trailing `%` divides by 100, so
|
||||
`200*15%` is `30`. The typed input also accepts `×`, `÷` and `−`.
|
||||
|
||||
Available constants: `pi`, `tau`, `e`.
|
||||
|
||||
Available functions: `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`,
|
||||
`sinh`, `cosh`, `tanh`, `ln`, `log`, `log2`, `log10`, `exp`, `sqrt`, `cbrt`,
|
||||
`abs`, `floor`, `ceil`, `round`, `trunc`, `sign`, `mod`, `pow`, `hypot`, `min`,
|
||||
`max`.
|
||||
|
||||
`log(x)` is base 10; `log(x, b)` is base `b`. `mod(a, b)` is the remainder, since
|
||||
`%` means percent here. The trigonometric functions follow the angle unit
|
||||
setting, shown in the panel header as `RAD` or `DEG`.
|
||||
|
||||
## Settings
|
||||
|
||||
### Plugin
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `precision` | `int` | `8` | Maximum decimals used when formatting results, 0 to 10. |
|
||||
| `angle_unit` | `select` | `rad` | Angle unit for the trigonometric functions. |
|
||||
|
||||
### Bar Widget
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `show_bar_value` | `bool` | `true` | Shows the last result next to the calculator glyph. |
|
||||
| `max_bar_length` | `int` | `9` | Longer results are shortened with a trailing `~`. |
|
||||
|
||||
## IPC
|
||||
|
||||
Beyond opening the panel, the `panel` entry accepts three events. The `all`
|
||||
target addresses every live instance.
|
||||
|
||||
```sh
|
||||
noctalia msg plugin yuuto/calculator:panel all eval "2+3*4"
|
||||
noctalia msg plugin yuuto/calculator:panel all insert "+8"
|
||||
noctalia msg plugin yuuto/calculator:panel all clear
|
||||
```
|
||||
|
||||
`eval` replaces the expression and evaluates it, `insert` appends to the current
|
||||
expression, and `clear` resets the calculator.
|
||||
|
||||
## Notes
|
||||
|
||||
Expressions are tokenized and parsed by the plugin itself, so evaluating never
|
||||
runs a shell command and no external tool is required. Results reach the
|
||||
clipboard through `noctalia.copyToClipboard`.
|
||||
|
||||
The panel keeps its model in the plugin state channel rather than in script
|
||||
locals, so clearing from the bar widget and reopening the panel stay in sync.
|
||||
|
||||
## Credits
|
||||
|
||||
Keypad layout and feature set are modelled on the v4 Quickshell calculator plugin
|
||||
by pir0c0pter0 (MIT).
|
||||
@@ -0,0 +1,94 @@
|
||||
--!nonstrict
|
||||
|
||||
local PANEL_ID = "yuuto/calculator:panel"
|
||||
|
||||
local result = "0"
|
||||
local expression = ""
|
||||
local hasError = false
|
||||
|
||||
local function maxLength()
|
||||
local value = noctalia.getConfig("max_bar_length")
|
||||
if type(value) ~= "number" then
|
||||
return 9
|
||||
end
|
||||
return math.clamp(math.floor(value), 3, 20)
|
||||
end
|
||||
|
||||
local function compact(text)
|
||||
local limit = maxLength()
|
||||
if #text <= limit then
|
||||
return text
|
||||
end
|
||||
return text:sub(1, math.max(1, limit - 1)) .. "~"
|
||||
end
|
||||
|
||||
local function badge()
|
||||
if noctalia.getConfig("show_bar_value") == false then
|
||||
return ""
|
||||
end
|
||||
if hasError then
|
||||
return noctalia.tr("state.error")
|
||||
end
|
||||
if result == "" or result == "0" then
|
||||
return ""
|
||||
end
|
||||
return compact(result)
|
||||
end
|
||||
|
||||
local function render()
|
||||
local container = barWidget.isVertical() and ui.column or ui.row
|
||||
local text = badge()
|
||||
local color = hasError and "error" or "on_surface"
|
||||
|
||||
barWidget.setTooltip(expression ~= "" and expression or noctalia.tr("bar.tooltip"))
|
||||
|
||||
local children = {
|
||||
ui.glyph({ name = "calculator", color = color }),
|
||||
}
|
||||
|
||||
if text ~= "" and not barWidget.isVertical() then
|
||||
table.insert(children, ui.label({ text = text, fontWeight = "bold", color = color }))
|
||||
end
|
||||
|
||||
barWidget.render(container({ gap = 6, align = "center" }, children))
|
||||
end
|
||||
|
||||
function update()
|
||||
render()
|
||||
end
|
||||
|
||||
noctalia.state.watch("calc.result", function(value)
|
||||
if type(value) == "string" then
|
||||
result = value
|
||||
render()
|
||||
end
|
||||
end)
|
||||
|
||||
noctalia.state.watch("calc.expression", function(value)
|
||||
if type(value) == "string" then
|
||||
expression = value
|
||||
render()
|
||||
end
|
||||
end)
|
||||
|
||||
noctalia.state.watch("calc.error", function(value)
|
||||
hasError = value == true
|
||||
render()
|
||||
end)
|
||||
|
||||
function onClick()
|
||||
noctalia.togglePanel(PANEL_ID)
|
||||
end
|
||||
|
||||
function onRightClick()
|
||||
noctalia.state.set("calc.result", "0")
|
||||
noctalia.state.set("calc.expression", "")
|
||||
noctalia.state.set("calc.last_expression", "")
|
||||
noctalia.state.set("calc.error", false)
|
||||
end
|
||||
|
||||
function onIpc(event, _payload)
|
||||
if event == "toggle" then
|
||||
noctalia.togglePanel(PANEL_ID)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,863 @@
|
||||
--!nonstrict
|
||||
|
||||
local DEG = math.pi / 180
|
||||
|
||||
local WIDE_OPERATORS = {
|
||||
["\u{00d7}"] = "*",
|
||||
["\u{00f7}"] = "/",
|
||||
["\u{2212}"] = "-",
|
||||
["\u{2013}"] = "-",
|
||||
["\u{00b7}"] = "*",
|
||||
}
|
||||
|
||||
local CONSTANTS = {
|
||||
pi = math.pi,
|
||||
tau = math.pi * 2,
|
||||
e = math.exp(1),
|
||||
}
|
||||
|
||||
local ARITY = {
|
||||
atan2 = 2,
|
||||
mod = 2,
|
||||
pow = 2,
|
||||
hypot = 2,
|
||||
}
|
||||
|
||||
local function sinh(x)
|
||||
return (math.exp(x) - math.exp(-x)) / 2
|
||||
end
|
||||
|
||||
local function cosh(x)
|
||||
return (math.exp(x) + math.exp(-x)) / 2
|
||||
end
|
||||
|
||||
local function tanh(x)
|
||||
if x > 20 then
|
||||
return 1
|
||||
end
|
||||
if x < -20 then
|
||||
return -1
|
||||
end
|
||||
local a, b = math.exp(x), math.exp(-x)
|
||||
return (a - b) / (a + b)
|
||||
end
|
||||
|
||||
local function isDigit(c)
|
||||
return c >= "0" and c <= "9"
|
||||
end
|
||||
|
||||
local function isAlpha(c)
|
||||
return (c >= "a" and c <= "z") or (c >= "A" and c <= "Z") or c == "_"
|
||||
end
|
||||
|
||||
local function tokenize(src)
|
||||
local tokens = {}
|
||||
local i = 1
|
||||
local n = #src
|
||||
|
||||
while i <= n do
|
||||
local wide = WIDE_OPERATORS[src:sub(i, i + 1)] or WIDE_OPERATORS[src:sub(i, i + 2)]
|
||||
if wide ~= nil then
|
||||
table.insert(tokens, { kind = "op", text = wide })
|
||||
i += WIDE_OPERATORS[src:sub(i, i + 1)] ~= nil and 2 or 3
|
||||
continue
|
||||
end
|
||||
|
||||
local c = src:sub(i, i)
|
||||
|
||||
if c == " " or c == "\t" then
|
||||
i += 1
|
||||
elseif isDigit(c) or (c == "." and isDigit(src:sub(i + 1, i + 1))) then
|
||||
local start = i
|
||||
while i <= n and isDigit(src:sub(i, i)) do
|
||||
i += 1
|
||||
end
|
||||
if src:sub(i, i) == "." then
|
||||
i += 1
|
||||
while i <= n and isDigit(src:sub(i, i)) do
|
||||
i += 1
|
||||
end
|
||||
end
|
||||
local mark = i
|
||||
if src:sub(i, i):lower() == "e" then
|
||||
local j = i + 1
|
||||
local sign = src:sub(j, j)
|
||||
if sign == "+" or sign == "-" then
|
||||
j += 1
|
||||
end
|
||||
if isDigit(src:sub(j, j)) then
|
||||
i = j
|
||||
while i <= n and isDigit(src:sub(i, i)) do
|
||||
i += 1
|
||||
end
|
||||
else
|
||||
i = mark
|
||||
end
|
||||
end
|
||||
local value = tonumber(src:sub(start, i - 1))
|
||||
if value == nil then
|
||||
return nil
|
||||
end
|
||||
table.insert(tokens, { kind = "num", value = value })
|
||||
elseif isAlpha(c) then
|
||||
local start = i
|
||||
while i <= n and (isAlpha(src:sub(i, i)) or isDigit(src:sub(i, i))) do
|
||||
i += 1
|
||||
end
|
||||
table.insert(tokens, { kind = "name", text = src:sub(start, i - 1):lower() })
|
||||
elseif c == "(" or c == ")" then
|
||||
table.insert(tokens, { kind = c })
|
||||
i += 1
|
||||
elseif c == "," or c == ";" then
|
||||
table.insert(tokens, { kind = "," })
|
||||
i += 1
|
||||
elseif c == "+" or c == "-" or c == "*" or c == "/" or c == "^" or c == "%" then
|
||||
table.insert(tokens, { kind = "op", text = c })
|
||||
i += 1
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
return tokens
|
||||
end
|
||||
|
||||
local function makeFunctions(degrees)
|
||||
local toAngle = degrees and function(x)
|
||||
return x * DEG
|
||||
end or function(x)
|
||||
return x
|
||||
end
|
||||
|
||||
local fromAngle = degrees and function(x)
|
||||
return x / DEG
|
||||
end or function(x)
|
||||
return x
|
||||
end
|
||||
|
||||
return {
|
||||
sin = function(a)
|
||||
return math.sin(toAngle(a[1]))
|
||||
end,
|
||||
cos = function(a)
|
||||
return math.cos(toAngle(a[1]))
|
||||
end,
|
||||
tan = function(a)
|
||||
return math.tan(toAngle(a[1]))
|
||||
end,
|
||||
asin = function(a)
|
||||
return fromAngle(math.asin(a[1]))
|
||||
end,
|
||||
acos = function(a)
|
||||
return fromAngle(math.acos(a[1]))
|
||||
end,
|
||||
atan = function(a)
|
||||
if #a >= 2 then
|
||||
return fromAngle(math.atan2(a[1], a[2]))
|
||||
end
|
||||
return fromAngle(math.atan(a[1]))
|
||||
end,
|
||||
atan2 = function(a)
|
||||
return fromAngle(math.atan2(a[1], a[2]))
|
||||
end,
|
||||
sinh = function(a)
|
||||
return sinh(a[1])
|
||||
end,
|
||||
cosh = function(a)
|
||||
return cosh(a[1])
|
||||
end,
|
||||
tanh = function(a)
|
||||
return tanh(a[1])
|
||||
end,
|
||||
ln = function(a)
|
||||
return math.log(a[1])
|
||||
end,
|
||||
log = function(a)
|
||||
if #a >= 2 then
|
||||
return math.log(a[1]) / math.log(a[2])
|
||||
end
|
||||
return math.log(a[1], 10)
|
||||
end,
|
||||
log2 = function(a)
|
||||
return math.log(a[1], 2)
|
||||
end,
|
||||
log10 = function(a)
|
||||
return math.log(a[1], 10)
|
||||
end,
|
||||
exp = function(a)
|
||||
return math.exp(a[1])
|
||||
end,
|
||||
sqrt = function(a)
|
||||
return math.sqrt(a[1])
|
||||
end,
|
||||
cbrt = function(a)
|
||||
local x = a[1]
|
||||
if x < 0 then
|
||||
return -((-x) ^ (1 / 3))
|
||||
end
|
||||
return x ^ (1 / 3)
|
||||
end,
|
||||
abs = function(a)
|
||||
return math.abs(a[1])
|
||||
end,
|
||||
floor = function(a)
|
||||
return math.floor(a[1])
|
||||
end,
|
||||
ceil = function(a)
|
||||
return math.ceil(a[1])
|
||||
end,
|
||||
round = function(a)
|
||||
return math.round(a[1])
|
||||
end,
|
||||
trunc = function(a)
|
||||
local x = a[1]
|
||||
return x >= 0 and math.floor(x) or math.ceil(x)
|
||||
end,
|
||||
sign = function(a)
|
||||
return math.sign(a[1])
|
||||
end,
|
||||
mod = function(a)
|
||||
return a[1] % a[2]
|
||||
end,
|
||||
pow = function(a)
|
||||
return a[1] ^ a[2]
|
||||
end,
|
||||
hypot = function(a)
|
||||
return math.sqrt(a[1] * a[1] + a[2] * a[2])
|
||||
end,
|
||||
min = function(a)
|
||||
return math.min(table.unpack(a))
|
||||
end,
|
||||
max = function(a)
|
||||
return math.max(table.unpack(a))
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local function evaluate(source, degrees)
|
||||
local tokens = tokenize(source)
|
||||
if tokens == nil or #tokens == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local functions = makeFunctions(degrees)
|
||||
local pos = 1
|
||||
local failed = false
|
||||
|
||||
local function peek()
|
||||
return tokens[pos]
|
||||
end
|
||||
|
||||
local function fail()
|
||||
failed = true
|
||||
return 0
|
||||
end
|
||||
|
||||
local parseExpression
|
||||
local parseUnary
|
||||
|
||||
local function parsePrimary()
|
||||
local token = peek()
|
||||
if token == nil then
|
||||
return fail()
|
||||
end
|
||||
|
||||
if token.kind == "num" then
|
||||
pos += 1
|
||||
return token.value
|
||||
end
|
||||
|
||||
if token.kind == "(" then
|
||||
pos += 1
|
||||
local value = parseExpression()
|
||||
local closing = peek()
|
||||
if closing == nil or closing.kind ~= ")" then
|
||||
return fail()
|
||||
end
|
||||
pos += 1
|
||||
return value
|
||||
end
|
||||
|
||||
if token.kind == "name" then
|
||||
pos += 1
|
||||
local name = token.text
|
||||
local following = peek()
|
||||
|
||||
if following ~= nil and following.kind == "(" then
|
||||
local fn = functions[name]
|
||||
if fn == nil then
|
||||
return fail()
|
||||
end
|
||||
pos += 1
|
||||
local args = {}
|
||||
if peek() ~= nil and peek().kind ~= ")" then
|
||||
table.insert(args, parseExpression())
|
||||
while peek() ~= nil and peek().kind == "," do
|
||||
pos += 1
|
||||
table.insert(args, parseExpression())
|
||||
end
|
||||
end
|
||||
local closing = peek()
|
||||
if closing == nil or closing.kind ~= ")" then
|
||||
return fail()
|
||||
end
|
||||
pos += 1
|
||||
if #args < (ARITY[name] or 1) then
|
||||
return fail()
|
||||
end
|
||||
return fn(args)
|
||||
end
|
||||
|
||||
local constant = CONSTANTS[name]
|
||||
if constant ~= nil then
|
||||
return constant
|
||||
end
|
||||
return fail()
|
||||
end
|
||||
|
||||
return fail()
|
||||
end
|
||||
|
||||
local function parsePostfix()
|
||||
local value = parsePrimary()
|
||||
while true do
|
||||
local token = peek()
|
||||
if token ~= nil and token.kind == "op" and token.text == "%" then
|
||||
pos += 1
|
||||
value /= 100
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
local function parsePower()
|
||||
local base = parsePostfix()
|
||||
local token = peek()
|
||||
if token ~= nil and token.kind == "op" and token.text == "^" then
|
||||
pos += 1
|
||||
return base ^ parseUnary()
|
||||
end
|
||||
return base
|
||||
end
|
||||
|
||||
function parseUnary()
|
||||
local token = peek()
|
||||
if token ~= nil and token.kind == "op" and (token.text == "-" or token.text == "+") then
|
||||
pos += 1
|
||||
local value = parseUnary()
|
||||
return token.text == "-" and -value or value
|
||||
end
|
||||
return parsePower()
|
||||
end
|
||||
|
||||
local function parseTerm()
|
||||
local value = parseUnary()
|
||||
while true do
|
||||
local token = peek()
|
||||
if token ~= nil and token.kind == "op" and (token.text == "*" or token.text == "/") then
|
||||
pos += 1
|
||||
local rhs = parseUnary()
|
||||
if token.text == "*" then
|
||||
value *= rhs
|
||||
else
|
||||
if rhs == 0 then
|
||||
return fail()
|
||||
end
|
||||
value /= rhs
|
||||
end
|
||||
elseif token ~= nil and token.kind == "(" then
|
||||
value *= parseUnary()
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
function parseExpression()
|
||||
local value = parseTerm()
|
||||
while true do
|
||||
local token = peek()
|
||||
if token ~= nil and token.kind == "op" and (token.text == "+" or token.text == "-") then
|
||||
pos += 1
|
||||
local rhs = parseTerm()
|
||||
if token.text == "+" then
|
||||
value += rhs
|
||||
else
|
||||
value -= rhs
|
||||
end
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
local ok, value = pcall(parseExpression)
|
||||
if not ok or failed or pos <= #tokens then
|
||||
return nil
|
||||
end
|
||||
if type(value) ~= "number" or value ~= value or value == math.huge or value == -math.huge then
|
||||
return nil
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
local function formatNumber(value, precision)
|
||||
if value == 0 then
|
||||
return "0"
|
||||
end
|
||||
|
||||
local magnitude = math.abs(value)
|
||||
if magnitude >= 1e16 or magnitude < 1e-9 then
|
||||
local text = string.format("%." .. math.min(precision, 8) .. "e", value)
|
||||
local mantissa, exponent = text:match("^(.-)e([-+]%d+)$")
|
||||
if mantissa ~= nil then
|
||||
if mantissa:find("%.") then
|
||||
mantissa = mantissa:gsub("0+$", ""):gsub("%.$", "")
|
||||
end
|
||||
local sign, digits = exponent:match("^([-+])0*(%d+)$")
|
||||
return mantissa .. "e" .. (sign == "-" and "-" or "") .. digits
|
||||
end
|
||||
return text
|
||||
end
|
||||
|
||||
local text = string.format("%." .. precision .. "f", value)
|
||||
if text:find("%.") then
|
||||
text = text:gsub("0+$", ""):gsub("%.$", "")
|
||||
end
|
||||
if text == "-0" then
|
||||
return "0"
|
||||
end
|
||||
return text
|
||||
end
|
||||
|
||||
local expression = ""
|
||||
local lastExpression = ""
|
||||
local result = "0"
|
||||
local lastPreview = nil
|
||||
local hasError = false
|
||||
local inputKey = 0
|
||||
|
||||
local function precision()
|
||||
local value = noctalia.getConfig("precision")
|
||||
if type(value) ~= "number" then
|
||||
return 8
|
||||
end
|
||||
return math.clamp(math.floor(value), 0, 10)
|
||||
end
|
||||
|
||||
local function useDegrees()
|
||||
return noctalia.getConfig("angle_unit") == "deg"
|
||||
end
|
||||
|
||||
local function restore()
|
||||
local storedResult = noctalia.state.get("calc.result")
|
||||
if type(storedResult) == "string" and storedResult ~= "" then
|
||||
result = storedResult
|
||||
end
|
||||
local storedExpression = noctalia.state.get("calc.expression")
|
||||
if type(storedExpression) == "string" then
|
||||
expression = storedExpression
|
||||
end
|
||||
local storedLast = noctalia.state.get("calc.last_expression")
|
||||
if type(storedLast) == "string" then
|
||||
lastExpression = storedLast
|
||||
end
|
||||
hasError = noctalia.state.get("calc.error") == true
|
||||
lastPreview = nil
|
||||
end
|
||||
|
||||
local function publish()
|
||||
noctalia.state.set("calc.result", result)
|
||||
noctalia.state.set("calc.expression", expression)
|
||||
noctalia.state.set("calc.last_expression", lastExpression)
|
||||
noctalia.state.set("calc.error", hasError)
|
||||
end
|
||||
|
||||
local function preview()
|
||||
if expression == "" then
|
||||
return nil
|
||||
end
|
||||
local value = evaluate(expression, useDegrees())
|
||||
if value == nil then
|
||||
return nil
|
||||
end
|
||||
return formatNumber(value, precision())
|
||||
end
|
||||
|
||||
local function displayValue()
|
||||
if hasError then
|
||||
return noctalia.tr("state.error")
|
||||
end
|
||||
if expression == "" then
|
||||
return result
|
||||
end
|
||||
local current = preview()
|
||||
if current ~= nil then
|
||||
lastPreview = current
|
||||
end
|
||||
return current or lastPreview or result
|
||||
end
|
||||
|
||||
local function toggleSign()
|
||||
if expression == "" then
|
||||
expression = "-"
|
||||
return
|
||||
end
|
||||
|
||||
local head, tail = expression:match("^(.-)([%d%.]+)$")
|
||||
if tail == nil then
|
||||
local last = expression:sub(-1)
|
||||
if last:match("[%+%-%*/%^%(]") then
|
||||
expression ..= "-"
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local before = head:sub(-1)
|
||||
if before == "-" then
|
||||
local previous = head:sub(-2, -2)
|
||||
if head == "-" or previous:match("[%+%-%*/%^%(,]") then
|
||||
expression = head:sub(1, -2) .. tail
|
||||
return
|
||||
end
|
||||
end
|
||||
expression = head .. "-" .. tail
|
||||
end
|
||||
|
||||
local render
|
||||
|
||||
local function clearAll()
|
||||
expression = ""
|
||||
lastExpression = ""
|
||||
result = "0"
|
||||
lastPreview = nil
|
||||
hasError = false
|
||||
inputKey += 1
|
||||
end
|
||||
|
||||
local function commit()
|
||||
if expression == "" then
|
||||
return
|
||||
end
|
||||
|
||||
local value = evaluate(expression, useDegrees())
|
||||
if value == nil then
|
||||
hasError = true
|
||||
lastExpression = expression
|
||||
expression = ""
|
||||
lastPreview = nil
|
||||
inputKey += 1
|
||||
return
|
||||
end
|
||||
|
||||
result = formatNumber(value, precision())
|
||||
lastPreview = result
|
||||
lastExpression = expression
|
||||
expression = result
|
||||
hasError = false
|
||||
inputKey += 1
|
||||
end
|
||||
|
||||
local function press(action, literal)
|
||||
if hasError and action ~= "clear" then
|
||||
hasError = false
|
||||
expression = ""
|
||||
lastExpression = ""
|
||||
end
|
||||
|
||||
if action == "clear" then
|
||||
clearAll()
|
||||
elseif action == "delete" then
|
||||
expression = expression:sub(1, -2)
|
||||
inputKey += 1
|
||||
elseif action == "sign" then
|
||||
toggleSign()
|
||||
inputKey += 1
|
||||
elseif action == "equals" then
|
||||
commit()
|
||||
else
|
||||
expression ..= literal
|
||||
inputKey += 1
|
||||
end
|
||||
|
||||
publish()
|
||||
render()
|
||||
end
|
||||
|
||||
local KEYPAD = {
|
||||
{
|
||||
{ label = "AC", handler = "onCalcClear", variant = "destructive" },
|
||||
{ label = "+/-", handler = "onCalcSign", variant = "secondary" },
|
||||
{ label = "%", handler = "onCalcPercent", variant = "secondary" },
|
||||
{ label = "DEL", handler = "onCalcDelete", variant = "secondary" },
|
||||
},
|
||||
{
|
||||
{ label = "7", handler = "onCalcSeven" },
|
||||
{ label = "8", handler = "onCalcEight" },
|
||||
{ label = "9", handler = "onCalcNine" },
|
||||
{ label = "/", handler = "onCalcDivide", variant = "secondary" },
|
||||
},
|
||||
{
|
||||
{ label = "4", handler = "onCalcFour" },
|
||||
{ label = "5", handler = "onCalcFive" },
|
||||
{ label = "6", handler = "onCalcSix" },
|
||||
{ label = "*", handler = "onCalcMultiply", variant = "secondary" },
|
||||
},
|
||||
{
|
||||
{ label = "1", handler = "onCalcOne" },
|
||||
{ label = "2", handler = "onCalcTwo" },
|
||||
{ label = "3", handler = "onCalcThree" },
|
||||
{ label = "-", handler = "onCalcMinus", variant = "secondary" },
|
||||
},
|
||||
{
|
||||
{ label = "0", handler = "onCalcZero", grow = 2 },
|
||||
{ label = ".", handler = "onCalcDecimal" },
|
||||
{ label = "+", handler = "onCalcPlus", variant = "secondary" },
|
||||
},
|
||||
{
|
||||
{ label = "(", handler = "onCalcOpen", variant = "ghost" },
|
||||
{ label = ")", handler = "onCalcClose", variant = "ghost" },
|
||||
{ label = "=", handler = "onCalcEquals", variant = "primary", grow = 2 },
|
||||
},
|
||||
}
|
||||
|
||||
local function keypadRow(row, rowIndex)
|
||||
local buttons = {}
|
||||
for index, spec in ipairs(row) do
|
||||
table.insert(
|
||||
buttons,
|
||||
ui.button({
|
||||
key = "k" .. rowIndex .. "-" .. index,
|
||||
text = spec.label,
|
||||
variant = spec.variant or "default",
|
||||
fontSize = 15,
|
||||
height = 34,
|
||||
flexGrow = spec.grow or 1,
|
||||
onClick = spec.handler,
|
||||
})
|
||||
)
|
||||
end
|
||||
return ui.row({ key = "row" .. rowIndex, gap = 6 }, buttons)
|
||||
end
|
||||
|
||||
function render()
|
||||
local shown = displayValue()
|
||||
local hint = hasError and lastExpression or (expression ~= "" and expression or lastExpression)
|
||||
local atRest = shown == "0" and expression == "" and lastExpression == ""
|
||||
local canCopy = not hasError and shown ~= "" and not atRest
|
||||
|
||||
local keypad = {}
|
||||
for index, row in ipairs(KEYPAD) do
|
||||
table.insert(keypad, keypadRow(row, index))
|
||||
end
|
||||
|
||||
panel.render(ui.column({ gap = 10, padding = 14 }, {
|
||||
ui.row({ gap = 8, align = "center" }, {
|
||||
ui.glyph({ name = "calculator", size = 16, color = "primary" }),
|
||||
ui.label({
|
||||
text = noctalia.tr("panel.title"),
|
||||
fontSize = 15,
|
||||
fontWeight = "bold",
|
||||
color = "on_surface",
|
||||
flexGrow = 1,
|
||||
}),
|
||||
ui.label({
|
||||
text = noctalia.tr(useDegrees() and "panel.deg" or "panel.rad"),
|
||||
fontSize = 11,
|
||||
color = "on_surface/0.55",
|
||||
}),
|
||||
}),
|
||||
|
||||
ui.column({ gap = 2, padding = 10, fill = "primary/0.08", radius = 10 }, {
|
||||
ui.label({
|
||||
text = hint,
|
||||
fontSize = 11,
|
||||
color = "on_surface/0.6",
|
||||
textAlign = "right",
|
||||
maxLines = 1,
|
||||
}),
|
||||
ui.row({ gap = 6, align = "center" }, {
|
||||
ui.label({
|
||||
text = shown,
|
||||
fontSize = 30,
|
||||
fontWeight = "bold",
|
||||
color = hasError and "error" or "on_surface",
|
||||
textAlign = "right",
|
||||
maxLines = 1,
|
||||
flexGrow = 1,
|
||||
}),
|
||||
ui.button({
|
||||
key = "copy",
|
||||
glyph = "copy",
|
||||
glyphSize = 15,
|
||||
variant = "ghost",
|
||||
controlSize = "sm",
|
||||
contentAlign = "center",
|
||||
tooltip = noctalia.tr("panel.copy"),
|
||||
visible = canCopy,
|
||||
onClick = "onCalcCopy",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
ui.input({
|
||||
key = "expr-" .. inputKey,
|
||||
value = expression,
|
||||
placeholder = noctalia.tr("panel.placeholder"),
|
||||
fontSize = 13,
|
||||
controlSize = "sm",
|
||||
focus = true,
|
||||
onChange = "onCalcInputChange",
|
||||
onSubmit = "onCalcInputSubmit",
|
||||
}),
|
||||
|
||||
ui.column({ gap = 6 }, keypad),
|
||||
}))
|
||||
end
|
||||
|
||||
function onCalcClear()
|
||||
press("clear")
|
||||
end
|
||||
|
||||
function onCalcSign()
|
||||
press("sign")
|
||||
end
|
||||
|
||||
function onCalcPercent()
|
||||
press("append", "%")
|
||||
end
|
||||
|
||||
function onCalcDelete()
|
||||
press("delete")
|
||||
end
|
||||
|
||||
function onCalcZero()
|
||||
press("append", "0")
|
||||
end
|
||||
|
||||
function onCalcOne()
|
||||
press("append", "1")
|
||||
end
|
||||
|
||||
function onCalcTwo()
|
||||
press("append", "2")
|
||||
end
|
||||
|
||||
function onCalcThree()
|
||||
press("append", "3")
|
||||
end
|
||||
|
||||
function onCalcFour()
|
||||
press("append", "4")
|
||||
end
|
||||
|
||||
function onCalcFive()
|
||||
press("append", "5")
|
||||
end
|
||||
|
||||
function onCalcSix()
|
||||
press("append", "6")
|
||||
end
|
||||
|
||||
function onCalcSeven()
|
||||
press("append", "7")
|
||||
end
|
||||
|
||||
function onCalcEight()
|
||||
press("append", "8")
|
||||
end
|
||||
|
||||
function onCalcNine()
|
||||
press("append", "9")
|
||||
end
|
||||
|
||||
function onCalcDecimal()
|
||||
press("append", ".")
|
||||
end
|
||||
|
||||
function onCalcPlus()
|
||||
press("append", "+")
|
||||
end
|
||||
|
||||
function onCalcMinus()
|
||||
press("append", "-")
|
||||
end
|
||||
|
||||
function onCalcMultiply()
|
||||
press("append", "*")
|
||||
end
|
||||
|
||||
function onCalcDivide()
|
||||
press("append", "/")
|
||||
end
|
||||
|
||||
function onCalcOpen()
|
||||
press("append", "(")
|
||||
end
|
||||
|
||||
function onCalcClose()
|
||||
press("append", ")")
|
||||
end
|
||||
|
||||
function onCalcEquals()
|
||||
press("equals")
|
||||
end
|
||||
|
||||
function onCalcCopy()
|
||||
local shown = displayValue()
|
||||
if hasError or shown == "" then
|
||||
return
|
||||
end
|
||||
if noctalia.copyToClipboard(shown, "text/plain") then
|
||||
noctalia.notify(noctalia.tr("panel.title"), noctalia.tr("panel.copied", { value = shown }))
|
||||
end
|
||||
end
|
||||
|
||||
function onCalcInputChange(value)
|
||||
expression = value
|
||||
hasError = false
|
||||
publish()
|
||||
render()
|
||||
end
|
||||
|
||||
function onCalcInputSubmit()
|
||||
press("equals")
|
||||
end
|
||||
|
||||
function onOpen(_context)
|
||||
restore()
|
||||
inputKey += 1
|
||||
render()
|
||||
end
|
||||
|
||||
function onIpc(event, payload)
|
||||
if event == "clear" then
|
||||
clearAll()
|
||||
elseif event == "eval" and type(payload) == "string" then
|
||||
expression = payload
|
||||
commit()
|
||||
elseif event == "insert" and type(payload) == "string" then
|
||||
expression ..= payload
|
||||
inputKey += 1
|
||||
else
|
||||
return
|
||||
end
|
||||
publish()
|
||||
render()
|
||||
end
|
||||
|
||||
noctalia.state.watch("calc.expression", function(value)
|
||||
if type(value) ~= "string" or value == expression then
|
||||
return
|
||||
end
|
||||
restore()
|
||||
inputKey += 1
|
||||
render()
|
||||
end)
|
||||
|
||||
restore()
|
||||
publish()
|
||||
@@ -0,0 +1,59 @@
|
||||
id = "yuuto/calculator"
|
||||
name = "Calculator"
|
||||
version = "1.0.0"
|
||||
plugin_api = 3
|
||||
author = "yuuto"
|
||||
license = "MIT"
|
||||
icon = "calculator"
|
||||
description = "A theme-aware calculator with a bar widget and a panel: full expression evaluation, a button grid, and typed input."
|
||||
dependencies = []
|
||||
tags = ["utility", "productivity", "bar", "panel"]
|
||||
|
||||
[[setting]]
|
||||
key = "precision"
|
||||
type = "int"
|
||||
label_key = "settings.precision.label"
|
||||
description_key = "settings.precision.description"
|
||||
default = 8
|
||||
min = 0
|
||||
max = 10
|
||||
|
||||
[[setting]]
|
||||
key = "angle_unit"
|
||||
type = "select"
|
||||
label_key = "settings.angle_unit.label"
|
||||
description_key = "settings.angle_unit.description"
|
||||
default = "rad"
|
||||
options = [
|
||||
{ value = "rad", label_key = "settings.angle_unit.options.rad" },
|
||||
{ value = "deg", label_key = "settings.angle_unit.options.deg" },
|
||||
]
|
||||
|
||||
[[widget]]
|
||||
id = "bar"
|
||||
entry = "bar.luau"
|
||||
|
||||
[[widget.setting]]
|
||||
key = "show_bar_value"
|
||||
type = "bool"
|
||||
label_key = "settings.show_bar_value.label"
|
||||
description_key = "settings.show_bar_value.description"
|
||||
default = true
|
||||
|
||||
[[widget.setting]]
|
||||
key = "max_bar_length"
|
||||
type = "int"
|
||||
label_key = "settings.max_bar_length.label"
|
||||
description_key = "settings.max_bar_length.description"
|
||||
default = 9
|
||||
min = 3
|
||||
max = 20
|
||||
|
||||
[[panel]]
|
||||
id = "panel"
|
||||
entry = "panel.luau"
|
||||
width = 320
|
||||
height = 452
|
||||
placement = "attached"
|
||||
position = "auto"
|
||||
open_near_click = true
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"bar": {
|
||||
"tooltip": "Rechner"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Rechner",
|
||||
"placeholder": "Ausdruck eingeben, Enter drücken",
|
||||
"copy": "Ergebnis kopieren",
|
||||
"copied": "Kopiert: {value}",
|
||||
"rad": "RAD",
|
||||
"deg": "GRAD"
|
||||
},
|
||||
"state": {
|
||||
"error": "Fehler"
|
||||
},
|
||||
"settings": {
|
||||
"precision": {
|
||||
"label": "Dezimalgenauigkeit",
|
||||
"description": "Maximale Anzahl Nachkommastellen bei der Ergebnisformatierung."
|
||||
},
|
||||
"angle_unit": {
|
||||
"label": "Winkeleinheit",
|
||||
"description": "Einheit, die die trigonometrischen Funktionen verwenden.",
|
||||
"options": {
|
||||
"rad": "Bogenmaß",
|
||||
"deg": "Grad"
|
||||
}
|
||||
},
|
||||
"show_bar_value": {
|
||||
"label": "Wert in der Leiste anzeigen",
|
||||
"description": "Zeigt das letzte Ergebnis neben dem Rechner-Symbol an."
|
||||
},
|
||||
"max_bar_length": {
|
||||
"label": "Maximale Länge in der Leiste",
|
||||
"description": "Längere Ergebnisse werden in der Leiste mit Auslassungspunkten gekürzt."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"bar": {
|
||||
"tooltip": "Calculator"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Calculator",
|
||||
"placeholder": "Type an expression, press Enter",
|
||||
"copy": "Copy result",
|
||||
"copied": "Copied {value}",
|
||||
"rad": "RAD",
|
||||
"deg": "DEG"
|
||||
},
|
||||
"state": {
|
||||
"error": "Error"
|
||||
},
|
||||
"settings": {
|
||||
"precision": {
|
||||
"label": "Decimal precision",
|
||||
"description": "Maximum number of decimals used when formatting results."
|
||||
},
|
||||
"angle_unit": {
|
||||
"label": "Angle unit",
|
||||
"description": "Unit used by the trigonometric functions.",
|
||||
"options": {
|
||||
"rad": "Radians",
|
||||
"deg": "Degrees"
|
||||
}
|
||||
},
|
||||
"show_bar_value": {
|
||||
"label": "Show value in bar",
|
||||
"description": "Display the last result next to the calculator icon."
|
||||
},
|
||||
"max_bar_length": {
|
||||
"label": "Maximum bar length",
|
||||
"description": "Longer results are shortened with an ellipsis in the bar."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user