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