├── .gitignore ├── .gitmodules ├── .luacheckrc ├── LICENSE ├── README.md ├── assets ├── composite.png ├── confirm.png ├── example.gif ├── list-multiple.png ├── list-single.png ├── logo.gvdesign ├── logo.png ├── password.png └── prompt.png ├── example-wrapped.lua ├── example.lua ├── sirocco-0.0.1-5.rockspec └── sirocco ├── char.lua ├── composite.lua ├── confirm.lua ├── init.lua ├── list.lua ├── password.lua ├── prompt.lua └── winsize.c /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Lua sources 2 | luac.out 3 | 4 | # luarocks build files 5 | *.src.rock 6 | *.zip 7 | *.tar.gz 8 | 9 | # Object files 10 | *.o 11 | *.os 12 | *.ko 13 | *.obj 14 | *.elf 15 | 16 | # Precompiled Headers 17 | *.gch 18 | *.pch 19 | 20 | # Libraries 21 | *.lib 22 | *.a 23 | *.la 24 | *.lo 25 | *.def 26 | *.exp 27 | 28 | # Shared objects (inc. Windows DLLs) 29 | *.dll 30 | *.so 31 | *.so.* 32 | *.dylib 33 | 34 | # Executables 35 | *.exe 36 | *.out 37 | *.app 38 | *.i*86 39 | *.x86_64 40 | *.hex 41 | 42 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/tui"] 2 | path = lib/tui 3 | url = https://github.com/daurnimator/lua-tui.git 4 | [submodule "lib/utf8_simple"] 5 | path = lib/utf8_simple 6 | url = https://github.com/blitmap/lua-utf8-simple.git 7 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | self = false --Ignore unused self warnings 2 | 3 | ignore = { 4 | "212" -- Unused argument 5 | } 6 | 7 | globals = { 8 | "string.utf8sub", 9 | "string.utf8width", 10 | "string.utf8height", 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Benoit Giannangeli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | sirocco 3 |

4 | 5 | # Sirocco 6 | A collection of interactive command line prompts for Lua 7 | 8 |

9 | sirocco 10 |

11 | 12 | **Note:** Sirocco is in active development. 13 | 14 | ## Installing 15 | 16 | Requirements: 17 | - Lua 5.1/JIT/5.2/5.3 18 | - luarocks >= 3.0 (_Note: `hererocks -rlatest` will install 2.4, you need to specify it with `-r3.0`_) 19 | 20 | ```bash 21 | luarocks install sirocco 22 | ``` 23 | 24 | ## Quickstart 25 | 26 | See [`example.lua`](https://github.com/giann/sirocco/blob/master/example.lua) for an exhaustive snippet of all sirocco's features. 27 | 28 | ### Text prompt 29 | 30 |

31 | Prompt 32 |

33 | 34 | Basic text prompt. Every prompt inherits from it so most of its options apply to them. 35 | 36 | ```lua 37 | Prompt { 38 | -- The prompt 39 | prompt = "A simple question\n❱ ", 40 | -- A placeholder that will dissappear once the user types something 41 | placeholder = "A simple answer", 42 | -- Whether the answer is required or not 43 | required = true, 44 | -- The default answer 45 | default = "A default answer", 46 | -- When hitting `tab`, will try to autocomplete based on those values 47 | possibleValues = { 48 | "some", 49 | "possible", 50 | "values", 51 | }, 52 | -- Must return whether the current text is valid + a message in case it's not 53 | validator = function(text) 54 | return text:match("[a-zA-Z]*"), "Message when not valid" 55 | end, 56 | -- If returns false, input will not appear at all 57 | filter = function(input) 58 | return input:match("[a-zA-Z]*") 59 | end 60 | }:ask() -- Returns the answer 61 | ``` 62 | 63 | ### Password 64 | 65 |

66 | Password 67 |

68 | 69 | Obfuscates the input. 70 | 71 | ```lua 72 | Password { 73 | prompt = "Enter your secret (hidden answer)\n❱ ", 74 | -- When false *** are printed otherwise nothing 75 | hidden = false 76 | }:ask() -- Returns the actual answer 77 | ``` 78 | 79 | ### Confirm 80 | 81 |

82 | Confirm 83 |

84 | 85 | A simple yes/no prompt. 86 | 87 | ```lua 88 | Confirm { 89 | prompt = "All finished?" 90 | }:ask() -- Returns the answer 91 | ``` 92 | 93 | ### List 94 | 95 |

96 | Single Choice List 97 |

98 | 99 |

100 | Multiple Choices List 101 |

102 | 103 | Will choose the appropriate list (check list or radio list). 104 | 105 | ```lua 106 | List { 107 | prompt = "Where are you from?", 108 | -- If true can select multiple choices (check list) otherwise one (radio list) 109 | multiple = false, 110 | -- List of choices 111 | items = { 112 | { 113 | -- The actual value returned if selected 114 | value = "New York", 115 | -- The value displayed to the user 116 | label = "New York" 117 | }, 118 | { 119 | value = "Paris", 120 | label = "Paris" 121 | }, 122 | { 123 | value = "Rome", 124 | label = "Rome" 125 | } 126 | }, 127 | -- Indexes of already selected choices 128 | default = { 2, 4 }, 129 | }:ask() -- Returns a table of the selected choices 130 | ``` 131 | 132 | ### Composite 133 | 134 |

135 | Composite 136 |

137 | 138 | Will jump from field to field when appropriate. 139 | 140 | **TODO:** field's length should be optional 141 | 142 | ```lua 143 | Composite { 144 | prompt = "What's your birthday? ", 145 | -- Separator between each fields 146 | separator = " / ", 147 | -- Fields definition 148 | fields = { 149 | { 150 | placeholder = "YYYY", 151 | filter = function(input) 152 | return input:match("%d") 153 | and input 154 | or "" 155 | end, 156 | -- Required 157 | length = 4, 158 | }, 159 | { 160 | placeholder = "mm", 161 | filter = function(input) 162 | return input:match("%d") 163 | and input 164 | or "" 165 | end, 166 | length = 2, 167 | }, 168 | { 169 | placeholder = "dd", 170 | filter = function(input) 171 | return input:match("%d") 172 | and input 173 | or "" 174 | end, 175 | length = 2, 176 | }, 177 | } 178 | }:ask() -- Returns a table of each field's answer 179 | ``` -------------------------------------------------------------------------------- /assets/composite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giann/sirocco/b2af2d336e808e763b424d2ea42e6a2c2b4aa24d/assets/composite.png -------------------------------------------------------------------------------- /assets/confirm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giann/sirocco/b2af2d336e808e763b424d2ea42e6a2c2b4aa24d/assets/confirm.png -------------------------------------------------------------------------------- /assets/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giann/sirocco/b2af2d336e808e763b424d2ea42e6a2c2b4aa24d/assets/example.gif -------------------------------------------------------------------------------- /assets/list-multiple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giann/sirocco/b2af2d336e808e763b424d2ea42e6a2c2b4aa24d/assets/list-multiple.png -------------------------------------------------------------------------------- /assets/list-single.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giann/sirocco/b2af2d336e808e763b424d2ea42e6a2c2b4aa24d/assets/list-single.png -------------------------------------------------------------------------------- /assets/logo.gvdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giann/sirocco/b2af2d336e808e763b424d2ea42e6a2c2b4aa24d/assets/logo.gvdesign -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giann/sirocco/b2af2d336e808e763b424d2ea42e6a2c2b4aa24d/assets/logo.png -------------------------------------------------------------------------------- /assets/password.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giann/sirocco/b2af2d336e808e763b424d2ea42e6a2c2b4aa24d/assets/password.png -------------------------------------------------------------------------------- /assets/prompt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giann/sirocco/b2af2d336e808e763b424d2ea42e6a2c2b4aa24d/assets/prompt.png -------------------------------------------------------------------------------- /example-wrapped.lua: -------------------------------------------------------------------------------- 1 | package.path = package.path .. ";./lib/tui/?/init.lua;./lib/tui/?.lua;./lib/utf8_simple/?.lua" 2 | 3 | local sirocco = require "sirocco" 4 | local Prompt = sirocco.prompt 5 | local List = sirocco.list 6 | 7 | -- Clear whole screen for demo 8 | -- io.write("\27[2J\27[1;1H") 9 | 10 | List { 11 | prompt = ("A long prompt that should wrap"):rep(10), 12 | required = true, 13 | items = { 14 | { 15 | value = "Hello", 16 | label = "Hello" 17 | }, 18 | { 19 | value = "Bonjour", 20 | label = "Bonjour" 21 | }, 22 | { 23 | value = "Ciao", 24 | label = "Ciao" 25 | }, 26 | }, 27 | }:ask() 28 | 29 | Prompt { 30 | prompt = ("A long prompt that should wrap"):rep(10) .. "\n❱ ", 31 | placeholder = "A simple answer", 32 | required = true 33 | }:ask() 34 | 35 | Prompt { 36 | prompt = "A simple question\n❱ ", 37 | placeholder = "A simple answer", 38 | }:ask() 39 | -------------------------------------------------------------------------------- /example.lua: -------------------------------------------------------------------------------- 1 | package.path = package.path .. ";./lib/tui/?/init.lua;./lib/tui/?.lua;./lib/utf8_simple/?.lua;./?/init.lua" 2 | 3 | local sirocco = require "sirocco" 4 | local Prompt = sirocco.prompt 5 | local List = sirocco.list 6 | local Password = sirocco.password 7 | local Confirm = sirocco.confirm 8 | local Composite = sirocco.composite 9 | local colors = require "term".colors 10 | 11 | -- Clear whole screen for demo 12 | -- io.write("\27[2J\27[1;1H") 13 | 14 | Prompt { 15 | prompt = "A simple question\n❱ ", 16 | placeholder = "A simple answer", 17 | required = true 18 | }:ask() 19 | 20 | Composite { 21 | prompt = "What's your birthday? ", 22 | separator = " / ", 23 | fields = { 24 | { 25 | placeholder = "YYYY", 26 | filter = function(input) 27 | return input:match("%d") 28 | and input 29 | or "" 30 | end, 31 | length = 4, 32 | }, 33 | { 34 | placeholder = "mm", 35 | filter = function(input) 36 | return input:match("%d") 37 | and input 38 | or "" 39 | end, 40 | length = 2, 41 | }, 42 | { 43 | placeholder = "dd", 44 | filter = function(input) 45 | return input:match("%d") 46 | and input 47 | or "" 48 | end, 49 | length = 2, 50 | }, 51 | } 52 | }:ask() 53 | 54 | Prompt { 55 | prompt = "Another question\n❱ ", 56 | default = "With a default answer", 57 | }:ask() 58 | 59 | Prompt { 60 | prompt = "What programming languages do you know ?\n❱ ", 61 | placeholder = "Try tab to get some suggestions...", 62 | possibleValues = { 63 | "lua", 64 | "c", 65 | "javascript", 66 | "php", 67 | "python", 68 | "rust", 69 | "go" 70 | } 71 | }:ask() 72 | 73 | Prompt { 74 | prompt = "What's you education level?", 75 | showPossibleValues = true, 76 | possibleValues = { 77 | "highschool", 78 | "college", 79 | "doctorate" 80 | }, 81 | validator = function(buffer) 82 | local ok = false 83 | for _, v in ipairs { 84 | "highschool", 85 | "college", 86 | "doctorate" 87 | } do 88 | if v == buffer then 89 | ok = true 90 | break 91 | end 92 | end 93 | 94 | return ok, not ok and colors.red .. "Not a valid answer" .. colors.reset 95 | end 96 | }:ask() 97 | 98 | List { 99 | prompt = "How do you say 'Hello'?", 100 | required = true, 101 | items = { 102 | { 103 | value = "Hello", 104 | label = "Hello" 105 | }, 106 | { 107 | value = "Bonjour", 108 | label = "Bonjour" 109 | }, 110 | { 111 | value = "Ciao", 112 | label = "Ciao" 113 | }, 114 | }, 115 | }:ask() 116 | 117 | List { 118 | prompt = "Here's a list with some already selected options:", 119 | default = { 2, 4 }, 120 | required = true, 121 | items = { 122 | { 123 | value = "First", 124 | label = "First" 125 | }, 126 | { 127 | value = "Second", 128 | label = "Second" 129 | }, 130 | { 131 | value = "Third", 132 | label = "Third" 133 | }, 134 | { 135 | value = "Fourth", 136 | label = "Fourth" 137 | }, 138 | }, 139 | }:ask() 140 | 141 | List { 142 | prompt = "Where are you from?", 143 | multiple = false, 144 | items = { 145 | { 146 | value = "New York", 147 | label = "New York" 148 | }, 149 | { 150 | value = "Paris", 151 | label = "Paris" 152 | }, 153 | { 154 | value = "Rome", 155 | label = "Rome" 156 | } 157 | }, 158 | }:ask() 159 | 160 | Password { 161 | prompt = "Enter your secret\n❱ ", 162 | }:ask() 163 | 164 | Password { 165 | prompt = "Enter your secret (hidden answer)\n❱ ", 166 | hidden = true 167 | }:ask() 168 | 169 | Prompt { 170 | prompt = "What's your birthday?\n❱ ", 171 | placeholder = "YYYY-mm-dd", 172 | validator = function(buffer) 173 | if utf8.len(buffer) > 0 then 174 | local matches = { buffer:match("([1-9][0-9][0-9][0-9])%-([0-1][0-9])%-([0-3][0-9])") } 175 | 176 | if matches[1] == nil 177 | or tonumber(matches[2]) > 12 178 | or tonumber(matches[3]) > 31 then 179 | return false, colors.yellow .. "Not a valid date!" .. colors.reset 180 | end 181 | end 182 | 183 | return true 184 | end 185 | }:ask() 186 | 187 | Prompt { 188 | prompt = "Only numbers allowed\n❱ ", 189 | filter = function(input) 190 | return input:match("%d") 191 | and input 192 | or "" 193 | end 194 | }:ask() 195 | 196 | Confirm { 197 | prompt = "All finished?" 198 | }:ask() 199 | -------------------------------------------------------------------------------- /sirocco-0.0.1-5.rockspec: -------------------------------------------------------------------------------- 1 | 2 | package = "sirocco" 3 | version = "0.0.1-5" 4 | rockspec_format = "3.0" 5 | 6 | source = { 7 | url = "git://github.com/giann/sirocco", 8 | } 9 | 10 | description = { 11 | summary = "A collection of useful cli prompts", 12 | homepage = "https://github.com/giann/sirocco", 13 | license = "MIT/X11", 14 | } 15 | 16 | build = { 17 | modules = { 18 | ["sirocco"] = "sirocco/init.lua", 19 | ["sirocco.composite"] = "sirocco/composite.lua", 20 | ["sirocco.confirm"] = "sirocco/confirm.lua", 21 | ["sirocco.list"] = "sirocco/list.lua", 22 | ["sirocco.password"] = "sirocco/password.lua", 23 | ["sirocco.prompt"] = "sirocco/prompt.lua", 24 | ["sirocco.char"] = "sirocco/char.lua", 25 | ["sirocco.winsize"] = "sirocco/winsize.c", 26 | ["tui"] = "lib/tui/tui/init.lua", 27 | ["tui.filters"] = "lib/tui/tui/filters.lua", 28 | ["tui.terminfo"] = "lib/tui/tui/terminfo.lua", 29 | ["tui.tparm"] = "lib/tui/tui/tparm.lua", 30 | ["tui.tput"] = "lib/tui/tui/tput.lua", 31 | ["tui.util"] = "lib/tui/tui/util.lua", 32 | ["utf8_simple"] = "lib/utf8_simple/utf8_simple.lua" 33 | }, 34 | type = "builtin", 35 | } 36 | 37 | dependencies = { 38 | "lua >= 5.1", 39 | "lua-term >= 0.7-1", 40 | "hump >= 0.4-2", 41 | "wcwidth >= 0.3-1", 42 | "compat53 >= 0.7-1", 43 | "bit32 >= 5.2.0-1" 44 | } 45 | -------------------------------------------------------------------------------- /sirocco/char.lua: -------------------------------------------------------------------------------- 1 | local wcwidth = require "wcwidth" 2 | local filters = require "tui.filters" 3 | 4 | local bit32 = require "bit32" 5 | local band = bit32.band 6 | local bor = bit32.bor 7 | local bnot = bit32.bnot 8 | 9 | -- Overwrite default filter to add utf8 10 | filters.default_chain = filters.make_chain { 11 | -- Always before CSI 12 | filters.mouse; 13 | -- These can be in any order 14 | filters.SS2; 15 | filters.SS3; 16 | filters.CSI; 17 | filters.OSC; 18 | filters.DCS; 19 | filters.SOS; 20 | filters.PM; 21 | filters.APC; 22 | -- Should be before ESC but after CSI and OSC 23 | filters.linux_quirks; 24 | filters.multibyte_UTF8; 25 | } 26 | 27 | local control_character_threshold = 0x020 -- Smaller than this is control. 28 | local control_character_mask = 0x1f -- 0x20 - 1 29 | local meta_character_threshold = 0x07f -- Larger than this is Meta. 30 | local control_character_bit = 0x40 -- 0x000000, must be off. 31 | local meta_character_bit = 0x080 -- x0000000, must be on. 32 | local largest_char = 255 -- Largest character value. 33 | 34 | local function ctrl_char(c) 35 | return c < control_character_threshold and band(c, 0x80) == 0 36 | end 37 | 38 | local function meta_char(c) 39 | return c > meta_character_threshold and c <= largest_char 40 | end 41 | 42 | 43 | local function ctrl(c) 44 | return string.char(band(c:byte(), control_character_mask)) 45 | end 46 | 47 | -- Nobody really has a Meta key, use Esc instead 48 | local function meta(c) 49 | return string.char(bor(c:byte(), meta_character_bit)) 50 | end 51 | 52 | local function Esc(c) 53 | return "\27" .. c 54 | end 55 | 56 | local function unMeta(c) 57 | return string.char(band(c:byte(), bnot(meta_character_bit))) 58 | end 59 | 60 | local function unCtrl(c) 61 | return string.upper(string.char(bor(c:byte(), control_character_bit))) 62 | end 63 | 64 | -- Utf8 aware sub 65 | string.utf8sub = require "utf8_simple".sub 66 | 67 | string.utf8width = function(self) 68 | -- First remove color escape codes 69 | local str = self:gsub("\27%[%d+m", "") 70 | 71 | local len = 0 72 | 73 | for _, rune in utf8.codes(str) do 74 | local l = wcwidth(rune) 75 | if l >= 0 then 76 | len = len + l 77 | end 78 | end 79 | 80 | return len 81 | end 82 | 83 | string.utf8height = function(self, width) 84 | local height = 1 85 | for line in self:gmatch("([^\n]*)\n") do 86 | height = height + 1 87 | 88 | for _ = width, line:utf8width(), width do 89 | height = height + 1 90 | end 91 | end 92 | 93 | return height 94 | end 95 | 96 | return { 97 | isC = ctrl_char, 98 | isM = meta_char, 99 | C = ctrl, 100 | M = meta, 101 | Esc = Esc, 102 | unM = unMeta, 103 | unC = unCtrl, 104 | } 105 | -------------------------------------------------------------------------------- /sirocco/composite.lua: -------------------------------------------------------------------------------- 1 | local Class = require "hump.class" 2 | local colors = require "term".colors 3 | 4 | local Prompt = require "sirocco.prompt" 5 | 6 | local Composite = Class { 7 | 8 | __includes = Prompt, 9 | 10 | init = function(self, options) 11 | self.fields = options.fields or {} 12 | self.separator = options.separator or " • " 13 | 14 | Prompt.init(self, options) 15 | 16 | for _, field in ipairs(self.fields) do 17 | field.buffer = field.default or "" 18 | end 19 | end 20 | 21 | } 22 | 23 | function Composite:moveOffsetBy(chars) 24 | local currentField, i = self:getCurrentField() 25 | local currentPosition = self.currentPosition.x - currentField.position 26 | 27 | if chars > 0 then 28 | -- Jump to text field 29 | if currentPosition + chars > currentField.length 30 | and i < #self.fields then 31 | self.currentPosition.x = self.fields[i + 1].position 32 | else 33 | chars = math.min(currentField.buffer:utf8width() - currentPosition, chars) 34 | 35 | if chars > 0 then 36 | self.currentPosition.x = self.currentPosition.x + chars 37 | end 38 | end 39 | elseif chars < 0 then 40 | -- Jump to previous field 41 | if currentPosition + chars < 0 42 | and i > 1 then 43 | local previousField = self.fields[i - 1] 44 | self.currentPosition.x = previousField.position + previousField.buffer:utf8width() 45 | else 46 | self.currentPosition.x = math.max(currentField.position, self.currentPosition.x + chars) 47 | end 48 | end 49 | end 50 | 51 | function Composite:render() 52 | Prompt.render(self) 53 | 54 | self:setCursor( 55 | self.promptPosition.x, 56 | self.promptPosition.y 57 | ) 58 | 59 | local len = #self.fields 60 | local fieldPosition = 0 61 | for i, field in ipairs(self.fields) do 62 | if not field.buffer or field.buffer:utf8width() == 0 then 63 | -- Truncate placeholder to field length 64 | local placeholder = (field.placeholder or ""):utf8sub(1, field.length) 65 | -- Add padding to match field length 66 | placeholder = placeholder .. (" "):rep(field.length - placeholder:utf8width()) 67 | 68 | self.output:write( 69 | colors.bright .. colors.black 70 | .. placeholder 71 | .. colors.reset 72 | 73 | .. (i < len and self.separator or "") 74 | ) 75 | 76 | if not field.position then 77 | field.position = fieldPosition 78 | end 79 | else 80 | local buffer = field.buffer .. (" "):rep(field.length - Prompt.len(field.buffer)) 81 | 82 | self.output:write( 83 | buffer 84 | .. (i < len and self.separator or "") 85 | ) 86 | end 87 | 88 | fieldPosition = fieldPosition + field.length + (i < len and self.separator:utf8width() or 0) 89 | end 90 | 91 | self:setCursor( 92 | self.promptPosition.x + self.currentPosition.x, 93 | self.promptPosition.y + self.currentPosition.y 94 | ) 95 | end 96 | 97 | function Composite:getCurrentField() 98 | local currentField 99 | 100 | local len = #self.fields 101 | local i = 1 102 | repeat 103 | currentField = self.fields[i] 104 | i = i + 1 105 | until (self.currentPosition.x >= currentField.position 106 | and self.currentPosition.x <= currentField.position + currentField.length) 107 | or i > len 108 | 109 | return currentField, i - 1 110 | end 111 | 112 | function Composite:processInput(input) 113 | -- Jump cursor to next field if necessary 114 | local len = #self.fields 115 | for i, field in ipairs(self.fields) do 116 | if self.currentPosition.x > field.position + field.length - 1 117 | and i < len 118 | and self.currentPosition.x < self.fields[i + 1].position then 119 | self.currentPosition.x = self.fields[i + 1].position 120 | end 121 | end 122 | 123 | -- Get current field 124 | local currentField = self:getCurrentField() 125 | 126 | -- Filter input 127 | input = currentField.filter 128 | and currentField.filter(input) 129 | or input 130 | 131 | if Prompt.len(currentField.buffer) >= currentField.length then 132 | input = "" 133 | end 134 | 135 | -- Insert in current field 136 | currentField.buffer = 137 | (currentField.buffer:utf8sub(1, self.currentPosition.x - currentField.position) 138 | .. input 139 | .. currentField.buffer:utf8sub(self.currentPosition.x + 1 - currentField.position)) 140 | 141 | -- Increment current position 142 | self.currentPosition.x = self.currentPosition.x + input:utf8width() 143 | 144 | -- Validation 145 | if currentField.validator then 146 | local _, message = currentField.validator(currentField.buffer) 147 | self.message = message 148 | end 149 | end 150 | 151 | function Composite:processedResult() 152 | local result = {} 153 | 154 | for _, field in ipairs(self.fields) do 155 | table.insert(result, field.buffer) 156 | end 157 | 158 | return result 159 | end 160 | 161 | -- TODO: redefine all Prompt command_ to operate on current field instead of buffer 162 | 163 | function Composite:command_end_of_line() 164 | local currentField = self:getCurrentField() 165 | self.currentPosition.x = currentField.position + currentField.length 166 | end 167 | 168 | function Composite:command_kill_line() 169 | local currentField = self:getCurrentField() 170 | currentField.buffer = currentField.buffer:utf8sub( 171 | 1, 172 | self.currentPosition.x - currentField.position 173 | ) 174 | end 175 | 176 | function Composite:command_delete_back() 177 | if self.currentPosition.x > 0 then 178 | self:moveOffsetBy(-1) 179 | 180 | -- Maybe we jumped back to previous field 181 | local currentField = self:getCurrentField() 182 | 183 | -- Delete char at currentPosition 184 | currentField.buffer = currentField.buffer:utf8sub(1, self.currentPosition.x - currentField.position) 185 | .. currentField.buffer:utf8sub(self.currentPosition.x + 2 - currentField.position) 186 | end 187 | end 188 | 189 | 190 | function Composite:command_complete() 191 | -- TODO 192 | end 193 | 194 | return Composite 195 | -------------------------------------------------------------------------------- /sirocco/confirm.lua: -------------------------------------------------------------------------------- 1 | local Class = require "hump.class" 2 | local colors = require "term".colors 3 | 4 | local Prompt = require "sirocco.prompt" 5 | local List = require "sirocco.list" 6 | local char = require "sirocco.char" 7 | local C, Esc = char.C, char.Esc 8 | 9 | local Confirm = Class { 10 | 11 | __includes = List, 12 | 13 | init = function(self, options) 14 | options.items = { 15 | { 16 | value = true, 17 | label = "Yes" 18 | }, 19 | { 20 | value = false, 21 | label = "No" 22 | }, 23 | } 24 | 25 | options.multiple = false 26 | 27 | List.init(self, options) 28 | 29 | self.currentChoice = #self.chosen > 0 30 | and self.chosen[1] 31 | or 1 32 | end 33 | 34 | } 35 | 36 | function Confirm:registerKeybinding() 37 | self.keybinding = { 38 | command_get_next_choice = { 39 | Prompt.escapeCodes.key_right, 40 | C "n", 41 | Esc "[C" -- backup 42 | }, 43 | 44 | command_get_previous_choice = { 45 | Prompt.escapeCodes.key_left, 46 | C "p", 47 | Esc "[D" -- backup 48 | }, 49 | 50 | -- TODO: those should be signals 51 | command_exit = { 52 | C "c", 53 | }, 54 | 55 | command_validate = { 56 | "\n", 57 | "\r" 58 | }, 59 | } 60 | end 61 | 62 | function Confirm:render() 63 | Prompt.render(self) 64 | 65 | self.output:write( 66 | " " 67 | .. (self.currentChoice == 1 68 | and colors.underscore 69 | or "") 70 | .. self.items[1].label 71 | .. colors.reset 72 | .. " / " 73 | .. (self.currentChoice == 2 74 | and colors.underscore 75 | or "") 76 | .. self.items[2].label 77 | .. colors.reset 78 | ) 79 | end 80 | 81 | function Confirm:endCondition() 82 | self.chosen = { 83 | [self.currentChoice] = true 84 | } 85 | 86 | return List.endCondition(self) 87 | end 88 | 89 | function Confirm:after(result) 90 | -- Show selected label 91 | self:setCursor(self.promptPosition.x, self.promptPosition.y) 92 | 93 | -- Clear down 94 | self.output:write(Prompt.escapeCodes.clr_eos) 95 | 96 | self.output:write(" " .. (result[1] and "Yes" or "No")) 97 | 98 | -- Show cursor 99 | self.output:write(Prompt.escapeCodes.cursor_visible) 100 | 101 | Prompt.after(self) 102 | end 103 | 104 | return Confirm 105 | -------------------------------------------------------------------------------- /sirocco/init.lua: -------------------------------------------------------------------------------- 1 | require "compat53" 2 | 3 | return { 4 | prompt = require "sirocco.prompt", 5 | password = require "sirocco.password", 6 | confirm = require "sirocco.confirm", 7 | composite = require "sirocco.composite", 8 | list = require "sirocco.list", 9 | } 10 | -------------------------------------------------------------------------------- /sirocco/list.lua: -------------------------------------------------------------------------------- 1 | local Class = require "hump.class" 2 | local colors = require "term".colors 3 | 4 | local Prompt = require "sirocco.prompt" 5 | local char = require "sirocco.char" 6 | local C, Esc = char.C, char.Esc 7 | 8 | local List = Class { 9 | 10 | __includes = Prompt, 11 | 12 | init = function(self, options) 13 | self.items = options.items or { 14 | -- { 15 | -- value = "a", 16 | -- label = "the first choice" 17 | -- } 18 | } 19 | 20 | self.multiple = true 21 | if options.multiple ~= nil then 22 | self.multiple = options.multiple 23 | end 24 | 25 | self.currentChoice = 1 26 | self.chosen = options.default or {} 27 | 28 | -- Don't let prompt use options.default 29 | options.default = nil 30 | 31 | Prompt.init(self, options) 32 | end 33 | 34 | } 35 | 36 | function List:getHeight() 37 | local everything = self.prompt 38 | 39 | if not self.prompt:match("\n$") then 40 | everything = everything .. "\n" 41 | end 42 | 43 | for i, item in ipairs(self.items) do 44 | local chosen = self.chosen[i] 45 | 46 | -- TODO: should not copy render 47 | everything = everything 48 | .. " " 49 | .. (i == self.currentChoice and "❱ " or " ") 50 | 51 | .. (self.multiple and "[" or "(") 52 | .. ( 53 | self.multiple 54 | and (chosen and "✔" or " ") 55 | or (chosen and "●" or " ") 56 | ) 57 | .. (self.multiple and "]" or ")") 58 | 59 | .. " " 60 | 61 | .. (chosen and colors.underscore or "") 62 | .. item.label 63 | 64 | .. "\n" 65 | end 66 | 67 | everything = everything 68 | .. (self.message or "message") -- At least something otherwise line is ignored by textHeight 69 | 70 | return everything:utf8height(self.terminalWidth) 71 | end 72 | 73 | function List:registerKeybinding() 74 | Prompt.registerKeybinding(self) 75 | 76 | self.keybinding = { 77 | command_get_next_choice = { 78 | Prompt.escapeCodes.key_down, 79 | C "n", 80 | Esc "[B" -- backup 81 | }, 82 | 83 | command_get_previous_choice = { 84 | Prompt.escapeCodes.key_up, 85 | C "p", 86 | Esc "[A" -- backup 87 | }, 88 | 89 | command_select_choice = { 90 | " " 91 | }, 92 | 93 | -- TODO: those should be signals 94 | command_exit = { 95 | C "c", 96 | }, 97 | 98 | command_validate = { 99 | "\n", 100 | "\r" 101 | }, 102 | } 103 | end 104 | 105 | function List:complete() 106 | end 107 | 108 | function List:setCurrentChoice(newChoice) 109 | self.currentChoice = math.max(1, math.min(#self.items, self.currentChoice + newChoice)) 110 | end 111 | 112 | function List:render() 113 | Prompt.render(self) 114 | 115 | -- List must begin under prompt 116 | if not self.prompt:match("\n$") then 117 | self.output:write("\n") 118 | end 119 | 120 | for i, item in ipairs(self.items) do 121 | local chosen = self.chosen[i] 122 | 123 | self.output:write( 124 | " " 125 | .. (i == self.currentChoice and "❱ " or " ") 126 | 127 | .. colors.magenta 128 | .. (self.multiple and "[" or "(") 129 | .. ( 130 | self.multiple 131 | and (chosen and "✔" or " ") 132 | or (chosen and "●" or " ") 133 | ) 134 | .. (self.multiple and "]" or ")") 135 | .. colors.reset 136 | 137 | .. " " 138 | 139 | .. (chosen and colors.underscore or "") 140 | .. colors.green 141 | .. item.label 142 | .. colors.reset 143 | 144 | .. "\n" 145 | ) 146 | end 147 | end 148 | 149 | function List:renderMessage() 150 | if self.message then 151 | self:setCursor( 152 | 1, 153 | self.promptPosition.y + self.currentPosition.y + #self.items + 1 154 | ) 155 | 156 | self.output:write(self.message) 157 | 158 | self:setCursor( 159 | self.promptPosition.x + self.currentPosition.x, 160 | self.promptPosition.y + self.currentPosition.y 161 | ) 162 | end 163 | end 164 | 165 | function List:processInput(input) 166 | end 167 | 168 | function List:processedResult() 169 | local result = {} 170 | for i, selected in pairs(self.chosen) do 171 | if selected then 172 | table.insert(result, self.items[i].value) 173 | end 174 | end 175 | 176 | return result 177 | end 178 | 179 | function List:endCondition() 180 | if self.finished == "force" then 181 | return true 182 | end 183 | 184 | local count = 0 185 | for _, v in pairs(self.chosen) do 186 | count = count + (v and 1 or 0) 187 | end 188 | 189 | local condition = not self.required or count > 0 190 | 191 | if self.finished and not condition then 192 | self.message = colors.red .. "Answer is required" .. colors.reset 193 | end 194 | 195 | self.finished = self.finished and (not self.required or count > 0) 196 | 197 | return self.finished 198 | end 199 | 200 | function List:before() 201 | -- Hide cursor 202 | self.output:write(Prompt.escapeCodes.cursor_invisible) 203 | -- Backup 204 | self.output:write(Esc "[?25l") 205 | 206 | Prompt.before(self) 207 | end 208 | 209 | function List:after(result) 210 | -- Show selected label 211 | self:setCursor(self.promptPosition.x, self.promptPosition.y) 212 | 213 | -- Clear down 214 | self.output:write(Prompt.escapeCodes.clr_eos) 215 | 216 | if result then 217 | self.output:write(" " .. (#result == 1 and tostring(result[1]) or table.concat(result, ", "))) 218 | end 219 | 220 | -- Show cursor 221 | self.output:write(Prompt.escapeCodes.cursor_visible) 222 | -- Backup 223 | self.output:write(Esc "[?25h") 224 | 225 | Prompt.after(self) 226 | end 227 | 228 | function List:command_get_next_choice() 229 | self:setCurrentChoice(1) 230 | end 231 | 232 | function List:command_get_previous_choice() 233 | self:setCurrentChoice(-1) 234 | end 235 | 236 | function List:command_select_choice() 237 | local count = 0 238 | for _, v in pairs(self.chosen) do 239 | count = count + (v and 1 or 0) 240 | end 241 | 242 | self.chosen[self.currentChoice] = not self.chosen[self.currentChoice] 243 | 244 | -- Only one choice allowed ? unselect previous choice 245 | if self.chosen[self.currentChoice] and not self.multiple and count > 0 then 246 | self.chosen = { 247 | [self.currentChoice] = true 248 | } 249 | 250 | self.message = nil 251 | end 252 | end 253 | 254 | return List 255 | -------------------------------------------------------------------------------- /sirocco/password.lua: -------------------------------------------------------------------------------- 1 | local Class = require "hump.class" 2 | 3 | local Prompt = require "sirocco.prompt" 4 | 5 | local Password = Class { 6 | 7 | __includes = Prompt, 8 | 9 | init = function(self, options) 10 | -- Can't suggest anything 11 | options.default = nil 12 | options.possibleValues = nil 13 | 14 | self.hidden = options.hidden 15 | 16 | Prompt.init(self, options) 17 | 18 | self.actual = "" 19 | end 20 | 21 | } 22 | 23 | function Password:renderDisplayBuffer() 24 | self.displayBuffer = self.hidden 25 | and "" 26 | or ("*"):rep(Prompt.len(self.buffer)) 27 | end 28 | 29 | function Password:processInput(input) 30 | Prompt.processInput(self, input) 31 | 32 | if self.hidden then 33 | self.currentPosition.x = 0 34 | end 35 | end 36 | 37 | function Password:complete() 38 | end 39 | 40 | return Password 41 | -------------------------------------------------------------------------------- /sirocco/prompt.lua: -------------------------------------------------------------------------------- 1 | require "compat53" 2 | 3 | local Class = require "hump.class" 4 | local winsize = require "sirocco.winsize" 5 | local char = require "sirocco.char" 6 | local C, M, Esc = char.C, char.M, char.Esc 7 | local tui = require "tui" 8 | local tparm = require "tui.tparm".tparm 9 | 10 | -- TODO: remove 11 | local term = require "term" 12 | local colors = term.colors 13 | 14 | local Prompt 15 | Prompt = Class { 16 | 17 | init = function(self, options) 18 | self.input = options.input or io.stdin 19 | self.output = options.output or io.stdout 20 | self.prompt = options.prompt or "> " 21 | self.placeholder = options.placeholder 22 | 23 | assert( 24 | not self.placeholder 25 | or not self.placeholder:find("\n"), 26 | "New line not allowed in placeholder" 27 | ) 28 | 29 | self.possibleValues = options.possibleValues or {} 30 | self.showPossibleValues = options.showPossibleValues 31 | self.validator = options.validator 32 | self.filter = options.filter 33 | 34 | self.required = false 35 | if options.required ~= nil then 36 | self.required = options.required 37 | end 38 | 39 | -- Printed buffer (can be wrapped, colored etc.) 40 | self.displayBuffer = options.default or "" 41 | -- Unaltered buffer 42 | self.buffer = options.default or "" 43 | -- Current utf8-aware offset in buffer (has to be translated in (x,y) cursor position) 44 | self.bufferOffset = options.default and options.default:utf8width() + 1 or 1 45 | 46 | self.currentPosition = { 47 | x = 0, 48 | y = 0 49 | } 50 | 51 | self.startingPosition = { 52 | x = false, 53 | y = false 54 | } 55 | 56 | self.promptPosition = { 57 | x = false, 58 | y = false 59 | } 60 | 61 | self.width = 80 62 | -- Height is prompt rows + message row 63 | self.height = 1 64 | 65 | -- Will be printed below 66 | self.message = nil 67 | 68 | self:registerKeybinding() 69 | end, 70 | 71 | -- Must be use for cursor position, NOT to get len of a string 72 | len = function(input) 73 | -- utf8.len will fail if input is not valid utf8 74 | return utf8.len(input) or #input 75 | end, 76 | 77 | } 78 | 79 | function Prompt:registerKeybinding() 80 | self.keybinding = { 81 | command_beg_of_line = { 82 | Prompt.escapeCodes.key_home, 83 | C "a", 84 | }, 85 | 86 | command_end_of_line = { 87 | Prompt.escapeCodes.key_end, 88 | C "e", 89 | }, 90 | 91 | command_backward_char = { 92 | Prompt.escapeCodes.key_left, 93 | C "b", 94 | Esc "[D" -- backup 95 | }, 96 | 97 | command_forward_char = { 98 | Prompt.escapeCodes.key_right, 99 | C "f", 100 | Esc "[C" -- backup 101 | }, 102 | 103 | command_complete = { 104 | Prompt.escapeCodes.tab 105 | }, 106 | 107 | command_kill_line = { 108 | C "k", 109 | }, 110 | 111 | command_clear_screen = { 112 | C "l", 113 | }, 114 | 115 | command_delete_back = { 116 | Prompt.escapeCodes.key_backspace, 117 | "\127" 118 | }, 119 | 120 | command_unix_line_discard = { 121 | C "u", 122 | }, 123 | 124 | command_unix_word_rubout = { 125 | C "w", 126 | Esc "\127" 127 | }, 128 | 129 | command_transpose_chars = { 130 | C "t", 131 | }, 132 | 133 | command_delete = { 134 | C "d", 135 | }, 136 | 137 | command_exit = { 138 | C "c", -- TODO: should be signal 139 | C "g" 140 | }, 141 | 142 | command_backward_word = { 143 | M "b", 144 | Esc "b", 145 | Esc(Prompt.escapeCodes.key_left), 146 | Esc "[1;3D", -- Alt+left on termite 147 | }, 148 | 149 | command_forward_word = { 150 | M "f", 151 | Esc "f", 152 | Esc(Prompt.escapeCodes.key_right), 153 | Esc "[1;3C", -- Alt+left on termite 154 | }, 155 | 156 | command_kill_word = { 157 | M "d", 158 | Esc "d" 159 | }, 160 | 161 | command_validate = { 162 | "\n", 163 | "\r" 164 | }, 165 | } 166 | end 167 | 168 | function Prompt:insertAtCurrentPosition(text) 169 | -- Insert text at currentPosition 170 | self.buffer = 171 | self.buffer:utf8sub(1, self.bufferOffset - 1) 172 | .. text 173 | .. self.buffer:utf8sub(self.bufferOffset) 174 | end 175 | 176 | -- Necessary because we erase everything each time 177 | -- and reposition to startingPosition 178 | function Prompt:getHeight() 179 | -- TODO: should not copy render 180 | local everything = 181 | self.prompt 182 | .. (self.showPossibleValues and #self.possibleValues > 0 183 | and " (" 184 | .. table.concat(self.possibleValues, ", ") 185 | .. ") " 186 | or "") 187 | .. (self.buffer or "") 188 | .. "\n" 189 | .. (self.message or "message") -- At least something otherwise line is ignored by textHeight 190 | 191 | return everything:utf8height(self.terminalWidth) 192 | end 193 | 194 | function Prompt:updateCurrentPosition() 195 | -- Some utf8 chars can take more than one cell 196 | local visualOffset = self.buffer:utf8sub(1, self.bufferOffset - 1):utf8width() + 1 197 | local offset = self.promptPosition.x + visualOffset 198 | 199 | local rows = 0 200 | while offset - 1 > self.terminalWidth do 201 | offset = offset - self.terminalWidth 202 | rows = rows + 1 203 | end 204 | 205 | self.currentPosition.x = offset - self.promptPosition.x - 1 206 | self.currentPosition.y = rows 207 | end 208 | 209 | -- Take value buffer and format/wrap it 210 | function Prompt:renderDisplayBuffer() 211 | -- Terminal wraps printed text on its own 212 | self.displayBuffer = self.buffer 213 | end 214 | 215 | -- Set offset and move cursor accordingly 216 | function Prompt:setOffset(offset) 217 | self.bufferOffset = offset 218 | self:updateCurrentPosition() 219 | end 220 | 221 | -- Move offset by increment and move cursor accordingly 222 | function Prompt:moveOffsetBy(chars) 223 | if chars > 0 then 224 | chars = math.min(Prompt.len(self.buffer) - self.bufferOffset + 1, chars) 225 | 226 | if chars > 0 then 227 | self.bufferOffset = self.bufferOffset + chars 228 | end 229 | elseif chars < 0 then 230 | self.bufferOffset = math.max(1, self.bufferOffset + chars) 231 | end 232 | 233 | self:updateCurrentPosition() 234 | end 235 | 236 | function Prompt:processInput(input) 237 | input = self.filter 238 | and self.filter(input) 239 | or input 240 | 241 | self:insertAtCurrentPosition(input) 242 | 243 | self.bufferOffset = self.bufferOffset + Prompt.len(input) 244 | 245 | self:updateCurrentPosition() 246 | 247 | if self.validator then 248 | local _, message = self.validator(self.buffer) 249 | self.message = message 250 | end 251 | 252 | self:renderDisplayBuffer() 253 | end 254 | 255 | function Prompt:handleInput() 256 | local input = tui.getnext() 257 | 258 | for command, keys in pairs(self.keybinding) do 259 | for _, key in ipairs(keys) do 260 | if key == input then 261 | self[command](self) 262 | return true 263 | end 264 | end 265 | end 266 | 267 | -- Not an escape code 268 | self:processInput(input) 269 | end 270 | 271 | function Prompt:render() 272 | -- Go back to start 273 | self:setCursor(self.startingPosition.x, self.startingPosition.y) 274 | 275 | -- Clear down 276 | self.output:write(Prompt.escapeCodes.clr_eos) 277 | 278 | local inlinePossibleValues = self.showPossibleValues and #self.possibleValues > 0 279 | and " (" 280 | .. table.concat(self.possibleValues, ", ") 281 | .. ") " 282 | or "" 283 | 284 | -- Print prompt 285 | self.output:write( 286 | colors.bright .. colors.blue 287 | .. self.prompt 288 | .. inlinePossibleValues 289 | .. colors.reset 290 | ) 291 | 292 | -- Print placeholder 293 | if self.placeholder 294 | and (not self.promptPosition.x or not self.promptPosition.y) 295 | and Prompt.len(self.displayBuffer) == 0 then 296 | self.output:write( 297 | colors.bright .. colors.black 298 | .. (self.placeholder or "") 299 | .. colors.reset 300 | ) 301 | end 302 | 303 | -- Print current value 304 | self.output:write(self.displayBuffer) 305 | 306 | -- First time we need to initialize current position 307 | if not self.promptPosition.x or not self.promptPosition.y then 308 | local x, y = self.startingPosition.x, self.startingPosition.y - 1 309 | local prompt = self.prompt .. inlinePossibleValues 310 | 311 | local promptHeight = prompt:utf8height(self.terminalWidth) 312 | 313 | local lastLine = prompt:sub(-1) ~= "\n" 314 | and prompt:match("([^\n]*)$") 315 | or "" 316 | 317 | self.promptPosition.x, self.promptPosition.y = 318 | x + lastLine:utf8width() + (self.showPossibleValues and inlinePossibleValues:utf8width() or 0), 319 | y + promptHeight 320 | end 321 | 322 | self:renderMessage() 323 | 324 | self:setCursor( 325 | self.promptPosition.x + self.currentPosition.x, 326 | self.promptPosition.y + self.currentPosition.y 327 | ) 328 | end 329 | 330 | function Prompt:renderMessage() 331 | if self.message then 332 | self:setCursor( 333 | 1, 334 | self.promptPosition.y + self.currentPosition.y + 1 335 | ) 336 | 337 | self.output:write(self.message) 338 | 339 | self:setCursor( 340 | self.promptPosition.x + self.currentPosition.x, 341 | self.promptPosition.y + self.currentPosition.y 342 | ) 343 | end 344 | end 345 | 346 | function Prompt:update() 347 | self.terminalWidth, self.terminalHeight = winsize() 348 | 349 | self:renderDisplayBuffer() 350 | 351 | self.width = self.terminalWidth 352 | self.height = self:getHeight() 353 | 354 | -- Scroll up if at the terminal's bottom 355 | local heightDelta = (self.startingPosition.y + self.height) - self.terminalHeight - 1 356 | if heightDelta > 0 then 357 | -- Scroll up 358 | self.output:write(tparm(Prompt.escapeCodes.parm_index, heightDelta)) 359 | 360 | -- Shift everything up 361 | self.startingPosition.y = self.startingPosition.y - heightDelta 362 | self.promptPosition.y = self.promptPosition.y 363 | and (self.promptPosition.y - heightDelta) 364 | or self.promptPosition.y 365 | end 366 | end 367 | 368 | function Prompt:processedResult() 369 | return self.buffer 370 | end 371 | 372 | function Prompt:endCondition() 373 | if self.finished == "force" then 374 | return true 375 | end 376 | 377 | if self.finished and self.required and Prompt.len(self.buffer) == 0 then 378 | self.finished = false 379 | self.message = colors.red .. "Answer is required" .. colors.reset 380 | end 381 | 382 | -- Only validate if required or if something is in the buffer 383 | if self.finished and self.validator and (self.required or Prompt.len(self.buffer) > 0) then 384 | local ok, message = self.validator(self.buffer) 385 | self.finished = self.finished and (ok or not self.required) 386 | self.message = message 387 | end 388 | 389 | return self.finished 390 | end 391 | 392 | function Prompt:getCursor() 393 | local y, x = tui.getnext():match("([0-9]*);([0-9]*)") 394 | return tonumber(x), tonumber(y) 395 | end 396 | 397 | function Prompt:setCursor(x, y) 398 | self.output:write(tparm(Prompt.escapeCodes.cursor_address, y, x)) 399 | end 400 | 401 | function Prompt:before() 402 | if self.input == io.stdin then 403 | -- Raw mode to get chars by chars 404 | os.execute("/usr/bin/env stty raw opost -echo 2> /dev/null") 405 | end 406 | 407 | -- Get current position 408 | self.output:write(Prompt.escapeCodes.user7) 409 | 410 | self.startingPosition.x, 411 | self.startingPosition.y = self:getCursor() 412 | 413 | -- Wrap prompt if needed 414 | self.terminalWidth, self.terminalHeight = winsize() 415 | self.height = self:getHeight() 416 | end 417 | 418 | function Prompt:after() 419 | self.output:write("\n") 420 | 421 | -- Restore normal mode 422 | os.execute("/usr/bin/env stty sane") 423 | end 424 | 425 | function Prompt:ask() 426 | self:before() 427 | 428 | repeat 429 | self:update() 430 | 431 | self:render() 432 | 433 | self:handleInput() 434 | until self:endCondition() 435 | 436 | local result = self:processedResult() 437 | 438 | self:after(result) 439 | 440 | return result 441 | end 442 | 443 | -- COMMANDS 444 | 445 | function Prompt:command_beg_of_line() -- Control-a 446 | self:setOffset(1) 447 | end 448 | 449 | function Prompt:command_backward_char() -- Control-b, left arrow 450 | self:moveOffsetBy(-1) 451 | end 452 | 453 | function Prompt:command_delete() -- Control-d 454 | if Prompt.len(self.buffer) > 0 then 455 | self.buffer = 456 | self.buffer:utf8sub(1, math.max(1, self.bufferOffset - 1)) 457 | .. self.buffer:utf8sub(self.bufferOffset + 1) 458 | else 459 | self:command_exit() 460 | end 461 | end 462 | 463 | function Prompt:command_end_of_line() -- Control-e 464 | self:setOffset(Prompt.len(self.buffer) + 1) 465 | end 466 | 467 | function Prompt:command_forward_char() -- Control-f, right arrow 468 | self:moveOffsetBy(1) 469 | end 470 | 471 | function Prompt:command_complete() -- Control-i, tab 472 | if #self.possibleValues > 0 then 473 | local matches = {} 474 | local count = 0 475 | for _, value in ipairs(self.possibleValues) do 476 | if value:utf8sub(1, #self.buffer) == self.buffer then 477 | table.insert(matches, value) 478 | count = count + 1 479 | end 480 | end 481 | 482 | if count > 1 then 483 | self.message = table.concat(matches, " ") 484 | elseif count == 1 then 485 | self.buffer = matches[1] 486 | self:setOffset(Prompt.len(self.buffer) + 1) 487 | 488 | if self.validator then 489 | local _, message = self.validator(self.buffer) 490 | self.message = message 491 | end 492 | end 493 | end 494 | end 495 | 496 | function Prompt:command_kill_line() -- Control-k 497 | self.buffer = self.buffer:utf8sub( 498 | 1, 499 | self.bufferOffset - 1 500 | ) 501 | end 502 | 503 | function Prompt:command_clear_screen() -- Control-l 504 | self:setCursor(1,1) 505 | 506 | self.startingPosition = { 507 | x = 1, 508 | y = 1 509 | } 510 | 511 | self.promptPosition = { 512 | x = false, 513 | y = false 514 | } 515 | 516 | self.output:write(Prompt.escapeCodes.clr_eos) 517 | end 518 | 519 | function Prompt:command_transpose_chars() -- Control-t 520 | local len = Prompt.len(self.buffer) 521 | if len > 1 and self.bufferOffset > 1 then 522 | local offset = math.max(1, (self.bufferOffset > len and len or self.bufferOffset) - 1) 523 | 524 | self.buffer = 525 | self.buffer:utf8sub(1, offset - 1) 526 | .. self.buffer:utf8sub(offset + 1, offset + 1) 527 | .. self.buffer:utf8sub(offset, offset) 528 | .. self.buffer:utf8sub(offset + 2) 529 | 530 | if self.bufferOffset <= len then 531 | self:moveOffsetBy(1) 532 | end 533 | end 534 | end 535 | 536 | function Prompt:command_unix_line_discard() -- Control-u 537 | self:setOffset(1) 538 | self.buffer = "" 539 | end 540 | 541 | function Prompt:command_unix_word_rubout() -- Control-w 542 | local s, e = self.buffer:utf8sub(1, self.bufferOffset - 1):find("[%g]+[^%g]*$") 543 | 544 | if s then 545 | self.buffer = self.buffer:utf8sub(1, s - 1) .. self.buffer:utf8sub(e + 1) 546 | self:moveOffsetBy(s - e - 1) 547 | end 548 | end 549 | 550 | function Prompt:command_delete_back() 551 | if self.bufferOffset > 1 then 552 | self:moveOffsetBy(-1) 553 | 554 | -- Delete char at currentPosition 555 | self.buffer = self.buffer:utf8sub(1, self.bufferOffset-1) 556 | .. self.buffer:utf8sub(self.bufferOffset + 1) 557 | end 558 | end 559 | 560 | function Prompt:command_validate() 561 | self.finished = true 562 | self.pendingBuffer = "" 563 | end 564 | 565 | function Prompt:command_abort() 566 | self.finished = "force" 567 | end 568 | 569 | function Prompt:command_exit() -- Control-g, Control-c 570 | self:after() 571 | os.exit() 572 | end 573 | 574 | -- See: https://github.com/Distrotech/readline/blob/master/emacs_keymap.c 575 | 576 | function Prompt:command_backward_word() -- Meta-b 577 | local s, e = self.buffer:utf8sub(1, self.bufferOffset - 1):find("[%g]+[^%g]*$") 578 | 579 | if e then 580 | self:moveOffsetBy(s - e - 1) 581 | end 582 | end 583 | 584 | function Prompt:command_kill_word() -- Meta-d 585 | local s, e = self.buffer:utf8sub(self.bufferOffset):find("[%g]+[^%g]*") 586 | s, e = self.bufferOffset + s - 1, self.bufferOffset + e - 1 587 | 588 | if s then 589 | self.buffer = 590 | self.buffer:utf8sub(1, s - 1) 591 | .. self.buffer:utf8sub(e + 1) 592 | end 593 | end 594 | 595 | function Prompt:command_forward_word() -- Meta-f 596 | local _, e = self.buffer:utf8sub(self.bufferOffset):find("[%g]+[^%g]*") 597 | 598 | if e then 599 | self:moveOffsetBy(e) 600 | end 601 | end 602 | 603 | function Prompt:command_transpose_words() -- Meta-t 604 | end 605 | 606 | function Prompt:command_get_next_history() -- Control-n 607 | end 608 | 609 | function Prompt:command_get_previous_history() -- Control-p 610 | end 611 | 612 | function Prompt:command_reverse_search_history() -- Control-r 613 | end 614 | 615 | function Prompt:command_forward_search_history() -- Control-s 616 | end 617 | 618 | function Prompt:command_yank() -- Control-y 619 | end 620 | 621 | function Prompt:command_undo_command() -- Control-_ 622 | end 623 | 624 | function Prompt:command_possible_completions() -- Meta-=, Meta-? 625 | end 626 | 627 | function Prompt:command_beginning_of_history() -- Meta-< 628 | end 629 | 630 | function Prompt:command_end_of_history() -- Meta-> 631 | end 632 | 633 | function Prompt:command_delete_horizontal_space() -- Meta-\ 634 | end 635 | 636 | function Prompt:command_capitalize_word() -- Meta-c 637 | end 638 | 639 | function Prompt:command_downcase_word() -- Meta-l 640 | end 641 | 642 | function Prompt:command_upcase_word() -- Meta-u 643 | end 644 | 645 | -- Here for reference, won't likely be implemented 646 | 647 | function Prompt:command_set_mark() -- Control-@ 648 | end 649 | 650 | function Prompt:command_rubout() -- Control-h 651 | end 652 | 653 | function Prompt:command_quoted_insert() -- Control-q v 654 | end 655 | 656 | function Prompt:command_char_search() -- Control-] 657 | end 658 | 659 | function Prompt:command_backward_kill_word() -- Meta-Control-h 660 | end 661 | 662 | function Prompt:command_tab_insert() -- Meta-Control-i 663 | end 664 | 665 | function Prompt:command_revert_line() -- Meta-Control-r 666 | end 667 | 668 | function Prompt:command_yank_nth_arg() -- Meta-Control-y 669 | end 670 | 671 | function Prompt:command_backward_char_search() -- Meta-Control-] 672 | end 673 | 674 | function Prompt:command_set_mark() -- Meta-SPACE 675 | end 676 | 677 | function Prompt:command_insert_comment() -- Meta-# 678 | end 679 | 680 | function Prompt:command_tilde_expand() -- Meta-&, Meta-~ 681 | end 682 | 683 | function Prompt:command_insert_completions() -- Meta-* 684 | end 685 | 686 | function Prompt:command_digit_argument() -- Meta-- 687 | end 688 | 689 | function Prompt:command_yank_last_arg() -- Meta-., Meta-_ 690 | end 691 | 692 | function Prompt:command_noninc_forward_search() -- Meta-n 693 | end 694 | 695 | function Prompt:command_noninc_reverse_search() -- Meta-p 696 | end 697 | 698 | function Prompt:command_revert_line() -- Meta-r 699 | end 700 | 701 | function Prompt:command_yank_pop() -- Meta-y 702 | end 703 | 704 | 705 | -- If can't find terminfo, fallback to minimal list of necessary codes 706 | local ok, terminfo = pcall(require "tui.terminfo".find) 707 | 708 | Prompt.escapeCodes = ok and terminfo or {} 709 | 710 | -- Make sure we got everything we need 711 | Prompt.escapeCodes.cursor_invisible = Prompt.escapeCodes.cursor_invisible or Esc "[?25l" 712 | Prompt.escapeCodes.cursor_visible = Prompt.escapeCodes.cursor_visible or Esc "[?25h" 713 | Prompt.escapeCodes.clr_eos = Prompt.escapeCodes.clr_eos or Esc "[J" 714 | Prompt.escapeCodes.cursor_address = Prompt.escapeCodes.cursor_address or Esc "[%i%p1%d;%p2%dH" 715 | -- Get cursor position (https://invisible-island.net/ncurses/terminfo.ti.html) 716 | Prompt.escapeCodes.user7 = Prompt.escapeCodes.user7 or Esc "[6n" 717 | Prompt.escapeCodes.key_left = Prompt.escapeCodes.key_left or Esc "[D" 718 | Prompt.escapeCodes.key_right = Prompt.escapeCodes.key_right or Esc "[C" 719 | Prompt.escapeCodes.key_down = Prompt.escapeCodes.key_down or Esc "[B" 720 | Prompt.escapeCodes.key_up = Prompt.escapeCodes.key_up or Esc "[A" 721 | Prompt.escapeCodes.key_backspace = Prompt.escapeCodes.key_backspace or "\127" 722 | Prompt.escapeCodes.tab = Prompt.escapeCodes.tab or "\9" 723 | Prompt.escapeCodes.key_home = Prompt.escapeCodes.key_home or Esc "0H" 724 | Prompt.escapeCodes.key_end = Prompt.escapeCodes.key_end or Esc "0F" 725 | Prompt.escapeCodes.key_enter = Prompt.escapeCodes.key_enter or Esc "0M" 726 | Prompt.escapeCodes.parm_index = Prompt.escapeCodes.parm_inde or Esc "[%p1%dS" 727 | 728 | return Prompt 729 | -------------------------------------------------------------------------------- /sirocco/winsize.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | int lua_winsize(lua_State *L) { 7 | struct winsize sz; 8 | 9 | ioctl(0, TIOCGWINSZ, &sz); 10 | 11 | lua_pushinteger(L, sz.ws_col); 12 | lua_pushinteger(L, sz.ws_row); 13 | 14 | return 2; 15 | } 16 | 17 | 18 | int luaopen_sirocco_winsize(lua_State *L) { 19 | lua_pushcfunction(L, lua_winsize); 20 | 21 | return 1; 22 | } 23 | --------------------------------------------------------------------------------