-- BlockOS installer with onboarding.
-- Generated by tools/build.py from src/installer.lua: every BlockOS file is bundled below,
-- so this single program can be run from Pastebin, GitHub or a disk.
local Version = "1.6.0"
local Files = {}
Files["startup.lua"] = [[
-- Boots BlockOS when it is installed.
if (fs.exists("/blockos/boot.lua")) then
shell.run("/blockos/boot.lua")
end
]]
Files["blockos/apps/about/app.cfg"] = [[
{ Name = "About BlockOS", Description = "Information about this computer.", Hidden = true, Single = true, Width = 34, Height = 14 }
]]
Files["blockos/apps/about/main.lua"] = [[
-- About BlockOS: version and hardware summary.
local Ui = BlockOS.Require("ui")
local Gfx = BlockOS.Require("gfx")
local Logo = BlockOS.Require("logo")
local Config = BlockOS.Require("config")
local Page = Ui.Page.new()
local function Facts()
local Free = fs.getFreeSpace("/")
local Capacity = fs.getCapacity and fs.getCapacity("/")
return {
{ "Version", Config.Version() },
{ "Computer", (os.getComputerLabel() or "Unnamed") .. " #" .. os.getComputerID() },
{ "Host", _HOST or os.version() },
{ "Storage", Gfx.FormatSize(Free) .. " free" .. (Capacity and (" of " .. Gfx.FormatSize(Capacity)) or "") },
{ "Type", (turtle and "Turtle" or (pocket and "Pocket" or "Computer")) .. (term.isColor() and " (Advanced)" or "") }
}
end
function Page:OnLayout(W, H)
self:Clear()
self:Add(Ui.Button.new({
X = math.floor((W - 20) / 2) + 1, Y = H - 1, W = 20, Text = "Software Update...", Style = "primary",
OnClick = function() BlockOS.Launch("settings", { "update" }) end
}))
end
function Page:OnDraw(T, Th)
local LogoW, LogoH = Logo.Size(Logo.Small)
Logo.Draw(T, math.floor((self.W - LogoW) / 2) + 1, 2, Logo.Small)
Gfx.Center(T, LogoH + 3, "BlockOS", Th.Text, Th.WindowBg)
local Y = LogoH + 5
for _, Fact in ipairs(Facts()) do
if (Y >= self.H - 1) then break end
Gfx.Write(T, 2, Y, Gfx.Pad(Fact[1], 9, "right"), Th.SubText, Th.WindowBg)
Gfx.Write(T, 12, Y, Gfx.Truncate(Fact[2], self.W - 12), Th.Text, Th.WindowBg)
Y = Y + 1
end
end
Page:Run()
]]
Files["blockos/apps/appstore/app.cfg"] = [[
{ Name = "App Store", Description = "Discover and install apps, or install from Pastebin.", Width = 46, Height = 15, Single = true, Icon = { Lines = { " A ", "\131\131\131" }, Fg = "white", Bg = "lightBlue" } }
]]
Files["blockos/apps/appstore/main.lua"] = [=[
-- App Store: browses repository catalogs, installs apps from Pastebin or URLs,
-- and manages installed apps.
local Ui = BlockOS.Require("ui")
local Gfx = BlockOS.Require("gfx")
local Apps = BlockOS.Require("apps")
local Config = BlockOS.Require("config")
local Updater = BlockOS.Require("updater")
local Page = Ui.Page.new()
local Tab = 1
local Catalog = {}
local Loading = false
local Status = ""
local Query = ""
local Selected = nil
local Form = { Code = "", Name = "", Icon = "", Color = 1, Fullscreen = false }
local IconColors = { "blue", "purple", "magenta", "red", "orange", "yellow", "lime", "green", "cyan", "lightBlue", "pink", "gray", "black" }
local function Installed()
local Result = {}
for _, App in ipairs(Apps.List(true)) do
if (not App.System) then
Result[App.StoreID or App.ID] = App
end
end
return Result
end
local function ResolveUrl(Base, Url)
if (Url:match("^https?://")) then return Url end
return Base:gsub("[^/]*$", "") .. Url
end
local function FetchCatalogs()
Loading = true
Catalog = {}
Status = "Loading catalogs..."
Page:Draw()
local Errors = 0
for _, Url in ipairs(Config.Settings().Repositories) do
local Body = Updater.Get(Url)
local Data = Body and textutils.unserialise(Body)
if (type(Data) == "table" and type(Data.Apps) == "table") then
for _, Entry in ipairs(Data.Apps) do
if (type(Entry) == "table" and Entry.ID and Entry.Name and type(Entry.Source) == "table") then
Entry.Repository = Data.Name or Url
Entry.Base = Url
table.insert(Catalog, Entry)
end
end
else
Errors = Errors + 1
end
end
table.sort(Catalog, function(A, B) return A.Name:lower() < B.Name:lower() end)
Loading = false
Status = #Catalog .. " apps" .. (Errors > 0 and (", " .. Errors .. " source(s) unavailable") or "")
end
local function DownloadSource(Entry)
local Source = Entry.Source
if (Source.Type == "pastebin") then
return Apps.FetchPastebin(Source.Code)
elseif (Source.Type == "url") then
return Apps.Fetch(ResolveUrl(Entry.Base or "", Source.Url))
elseif (Source.Type == "rom") then
return "-- Launcher generated by the App Store\nshell.run(" .. string.format("%q", Source.Program) .. ", ...)\n"
end
return nil, "Unknown source type"
end
local function InstallEntry(Entry)
Status = "Downloading " .. Entry.Name .. "..."
Page:Draw()
local Source, Err = DownloadSource(Entry)
if (not Source) then
Status = ""
Ui.Alert("Install failed", tostring(Err))
return
end
local Ok, CompileErr = load(Source, "=" .. Entry.ID)
if (not Ok and not Ui.Confirm("Warning", "This app has a syntax error and may not run:\n" .. tostring(CompileErr), "Install Anyway")) then
Status = ""
return
end
local Existing = Installed()[Entry.ID]
Apps.Install({
ID = Existing and Existing.ID or Entry.ID,
Name = Entry.Name,
Description = Entry.Description,
Author = Entry.Author,
Category = Entry.Category,
Version = Entry.Version,
StoreID = Entry.ID,
Mode = Entry.Mode,
Width = Entry.Width,
Height = Entry.Height,
Icon = Entry.Icon,
Source = Entry.Source
}, Source)
Status = Entry.Name .. " installed"
BlockOS.Notify("App Store", Entry.Name .. " is ready in Launchpad.", { AppID = "appstore", Sound = false })
end
local function Filtered()
local Result = {}
local Needle = Query:lower()
for _, Entry in ipairs(Catalog) do
if (Needle == "" or Entry.Name:lower():find(Needle, 1, true) or (Entry.Category or ""):lower():find(Needle, 1, true)) then
table.insert(Result, Entry)
end
end
return Result
end
local Build
local function Header(W)
local TabsW = math.min(W - 2, 44)
Page:Add(Ui.Tabs.new({
X = math.floor((W - TabsW) / 2) + 1, Y = 1, W = TabsW, Items = { "Discover", "Installed", "Pastebin", "Sources" }, Selected = Tab,
OnChange = function(_, Index)
Tab = Index
Build()
end
}))
end
local function BuildDiscover(W, H)
local ListW = (W >= 40) and math.floor(W * .45) or W
local Search = Page:Add(Ui.Input.new({ X = 2, Y = 3, W = ListW - 2, Value = Query, Placeholder = "\16 Search apps" }))
local List = Page:Add(Ui.List.new({ X = 1, Y = 5, W = ListW, H = H - 6 }))
local Have = Installed()
local function Fill()
local Items = {}
for _, Entry in ipairs(Filtered()) do
local Mine = Have[Entry.ID]
local Label = "GET"
if (Mine) then
Label = (Entry.Version and Mine.Version and Updater.Compare(Mine.Version, Entry.Version) < 0) and "UPDATE" or "OPEN"
end
local Icon = Entry.Icon or Apps.DefaultIcon(Entry.Name)
table.insert(Items, {
Text = Entry.Name,
Icon = Gfx.Pad(Icon.Lines and Icon.Lines[1] or "", 3),
IconFg = colors[Icon.Bg] or Icon.Bg or colors.gray,
Right = Label,
Entry = Entry
})
end
List:SetItems(Items)
for Index, Item in ipairs(Items) do
if (Item.Entry == Selected) then List:Select(Index) end
end
end
Search.OnChange = function(_, Value)
Query = Value
Fill()
end
local function Act(Entry)
local Mine = Installed()[Entry.ID]
if (Mine and not (Entry.Version and Mine.Version and Updater.Compare(Mine.Version, Entry.Version) < 0)) then
BlockOS.Launch(Mine.ID)
else
InstallEntry(Entry)
Build()
end
end
local Action
local function ActionText()
local Mine = Selected and Installed()[Selected.ID]
if (not Mine) then return "Get" end
if (Selected.Version and Mine.Version and Updater.Compare(Mine.Version, Selected.Version) < 0) then return "Update" end
return "Open"
end
List.OnSelect = function(_, _, Item)
Selected = Item.Entry
if (Action) then
Action.Enabled = true
Page:Invalidate()
end
end
List.OnActivate = function(_, _, Item)
if (ListW == W) then
local Entry = Item.Entry
local Choice = Ui.Alert(Entry.Name, (Entry.Description or "") .. "\n\nBy " .. (Entry.Author or "unknown"), { "Close", Item.Right == "GET" and "Get" or (Item.Right == "UPDATE" and "Update" or "Open") })
if (Choice == 2) then Act(Entry) end
else
Act(Item.Entry)
end
end
Fill()
if (ListW < W) then
local DetailX = ListW + 2
local DetailW = W - ListW - 2
Page:Add(Ui.Canvas.new({
X = DetailX, Y = 3, W = DetailW, H = H - 6,
OnDraw = function(Self, T, Th)
local Entry = Selected
if (not Entry) then
Gfx.Write(T, Self.X, Self.Y + 1, Gfx.Truncate(Loading and "Loading..." or "Select an app", Self.W), Th.SubText, Th.WindowBg)
return
end
local Icon = Entry.Icon or Apps.DefaultIcon(Entry.Name)
local Fg = colors[Icon.Fg] or Icon.Fg or colors.white
local Bg = colors[Icon.Bg] or Icon.Bg or colors.gray
for Row = 1, 2 do
Gfx.Write(T, Self.X, Self.Y + Row - 1, Gfx.Pad(Icon.Lines and Icon.Lines[Row] or "", 3), Fg, Bg)
end
Gfx.Write(T, Self.X + 4, Self.Y, Gfx.Truncate(Entry.Name, Self.W - 4), Th.Text, Th.WindowBg)
Gfx.Write(T, Self.X + 4, Self.Y + 1, Gfx.Truncate((Entry.Category or "App") .. " \7 " .. (Entry.Author or "?"), Self.W - 4), Th.SubText, Th.WindowBg)
local Lines = Gfx.Wrap(Entry.Description or "", Self.W)
for Row = 1, math.min(#Lines, Self.H - 5) do
Gfx.Write(T, Self.X, Self.Y + 2 + Row, Lines[Row], Th.Text, Th.WindowBg)
end
Gfx.Write(T, Self.X, Self.Y + Self.H - 1, Gfx.Truncate("Version " .. (Entry.Version or "?") .. " from " .. (Entry.Repository or ""), Self.W), Th.SubText, Th.WindowBg)
end
}))
Action = Page:Add(Ui.Button.new({
X = W - 9, Y = H - 2, W = 8, Text = ActionText, Style = "primary", Enabled = Selected ~= nil,
OnClick = function()
if (Selected) then Act(Selected) end
end
}))
end
end
local function BuildInstalled(W, H)
local List = Page:Add(Ui.List.new({ X = 1, Y = 3, W = W, H = H - 4 }))
local Items = {}
for _, App in ipairs(Apps.List(true)) do
if (not App.System) then
table.insert(Items, {
Text = App.Name,
Icon = Gfx.Pad(App.Icon.Lines[1], 3),
IconFg = App.Icon.Bg,
Right = App.Version or "",
App = App
})
end
end
if (#Items == 0) then
Page:Add(Ui.Label.new({ X = 2, Y = 4, W = W - 2, Text = "No apps installed yet.", Fg = "SubText" }))
end
List:SetItems(Items)
local function Menu(Item, X, Y)
local App = Item.App
local Pinned = Apps.IsPinned(App.ID)
local Options = { "Open", "Open Full Screen", Pinned and "Remove from Dock" or "Keep in Dock", "Edit Source", "-", "Uninstall" }
local Choice = Options[Ui.ContextMenu(X, Y, Options) or 0]
if (Choice == "Open") then
BlockOS.Launch(App.ID)
elseif (Choice == "Open Full Screen") then
BlockOS.Launch(App.ID, {}, { Mode = "fullscreen" })
elseif (Choice == "Keep in Dock" or Choice == "Remove from Dock") then
Apps.SetPinned(App.ID, not Pinned)
elseif (Choice == "Edit Source") then
BlockOS.Launch("editor", { App.Path })
elseif (Choice == "Uninstall") then
if (Ui.Confirm("Uninstall", "Remove " .. App.Name .. "?", "Uninstall")) then
Apps.Uninstall(App.ID)
Build()
end
end
end
List.OnActivate = function(_, _, Item) BlockOS.Launch(Item.App.ID) end
List.OnContext = function(_, _, Item, X, Y)
if (Item) then Menu(Item, X, Y) end
end
Page:Add(Ui.Label.new({ X = 2, Y = H - 1, W = W - 2, Text = "Right-click an app for more options.", Fg = "SubText" }))
end
local function BuildPastebin(W, H)
local FieldW = math.min(W - 16, 28)
Page:Add(Ui.Label.new({ X = 2, Y = 3, W = W - 2, Text = "Install any program from Pastebin or a URL as an app.", Fg = "SubText" }))
Page:Add(Ui.Label.new({ X = 2, Y = 5, Text = "Code / URL" }))
local Code = Page:Add(Ui.Input.new({ X = 14, Y = 5, W = FieldW, Value = Form.Code, Placeholder = "e.g. aBcD1234" }))
Code.OnChange = function(_, Value) Form.Code = Value end
Page:Add(Ui.Label.new({ X = 2, Y = 7, Text = "Name" }))
local Name = Page:Add(Ui.Input.new({ X = 14, Y = 7, W = FieldW, Value = Form.Name, Placeholder = "My App", MaxLength = 24 }))
Name.OnChange = function(_, Value) Form.Name = Value end
Page:Add(Ui.Label.new({ X = 2, Y = 9, Text = "Icon" }))
local Icon = Page:Add(Ui.Input.new({ X = 14, Y = 9, W = 4, Value = Form.Icon, MaxLength = 3 }))
Icon.OnChange = function(_, Value) Form.Icon = Value end
Page:Add(Ui.Canvas.new({
X = 19, Y = 9, W = 3, H = 1,
OnDraw = function(Self, T)
local Text = (#Form.Icon > 0) and Form.Icon or Apps.DefaultIcon(Form.Name ~= "" and Form.Name or "App").Lines[1]
Gfx.Write(T, Self.X, Self.Y, Gfx.Pad(Text, 3), colors.white, colors[IconColors[Form.Color]])
end,
OnEvent = function(_, EventName)
if (EventName ~= "mouse_click") then return false end
Form.Color = Form.Color % #IconColors + 1
return true
end
}))
Page:Add(Ui.Label.new({ X = 23, Y = 9, Text = "\27 click for colour", Fg = "SubText" }))
Page:Add(Ui.Label.new({ X = 2, Y = 11, Text = "Open as" }))
Page:Add(Ui.Tabs.new({
X = 14, Y = 11, W = 22, Items = { "Window", "Full Screen" }, Selected = Form.Fullscreen and 2 or 1,
OnChange = function(_, Index) Form.Fullscreen = Index == 2 end
}))
Page:Add(Ui.Button.new({
X = W - 11, Y = H - 1, W = 10, Text = "Install", Style = "primary",
OnClick = function()
local Input = Form.Code:gsub("^%s+", ""):gsub("%s+$", "")
if (#Input == 0) then
Ui.Alert("Install", "Enter a Pastebin code or a URL.")
return
end
local AppName = (#Form.Name > 0) and Form.Name or Input:match("([%w_%-]+)%.?%w*$") or "App"
Status = "Downloading..."
Page:Draw()
local Source, Err
local SourceInfo
if (Input:match("^https?://") and not Input:match("pastebin%.com")) then
Source, Err = Apps.Fetch(Input)
SourceInfo = { Type = "url", Url = Input }
else
Source, Err = Apps.FetchPastebin(Input)
SourceInfo = { Type = "pastebin", Code = Input }
end
Status = ""
if (not Source) then
Ui.Alert("Install failed", tostring(Err))
return
end
local Lines = (#Form.Icon > 0) and { Gfx.Pad(Form.Icon, 3), " " } or Apps.DefaultIcon(AppName).Lines
local App = Apps.Install({
Name = AppName,
Description = "Installed from " .. (SourceInfo.Type == "pastebin" and ("Pastebin " .. Input) or Input),
Mode = Form.Fullscreen and "fullscreen" or "window",
Icon = { Lines = Lines, Fg = "white", Bg = IconColors[Form.Color] },
Source = SourceInfo,
Version = "1.0"
}, Source)
Form = { Code = "", Name = "", Icon = "", Color = Form.Color, Fullscreen = false }
if (App and Ui.Confirm("Installed", AppName .. " was added to Applications. Open it now?", "Open")) then
BlockOS.Launch(App.ID)
end
Build()
end
}))
Page:SetFocus(Code)
end
local function BuildSources(W, H)
local Settings = Config.Settings()
local List = Page:Add(Ui.List.new({ X = 1, Y = 3, W = W, H = H - 5 }))
local Items = {}
for Index, Url in ipairs(Settings.Repositories) do
table.insert(Items, { Text = Url, Icon = "\4", IconFg = "Accent", Index = Index })
end
List:SetItems(Items)
Page:Add(Ui.Button.new({
X = 2, Y = H - 1, Text = " Add Source ",
OnClick = function()
local Url = Ui.Prompt("Add Source", "Catalog URL (catalog.cfg):", "https://")
if (Url and Url:match("^https?://.+")) then
table.insert(Settings.Repositories, Url)
Config.Save("settings")
Build()
end
end
}))
Page:Add(Ui.Button.new({
X = 16, Y = H - 1, Text = " Remove ",
OnClick = function()
local Item = List.Items[List.Selected]
if (Item and Ui.Confirm("Remove Source", Item.Text, "Remove")) then
table.remove(Settings.Repositories, Item.Index)
Config.Save("settings")
Build()
end
end
}))
Page:Add(Ui.Button.new({
X = W - 10, Y = H - 1, Text = " Reload ", Style = "primary",
OnClick = function()
FetchCatalogs()
Tab = 1
Build()
end
}))
end
function Build()
Page:Clear()
local W, H = Page.W, Page.H
Header(W)
if (Tab == 1) then
BuildDiscover(W, H)
elseif (Tab == 2) then
BuildInstalled(W, H)
elseif (Tab == 3) then
BuildPastebin(W, H)
else
BuildSources(W, H)
end
Page:Add(Ui.Label.new({ X = 1, Y = H, W = W, Text = function() return Status end, Fg = "SubText", Bg = "Panel", Align = "center" }))
Page:Invalidate()
end
function Page:OnLayout()
Build()
end
function Page:OnEvent(Name)
if (Name == "appstore_refresh") then
FetchCatalogs()
Build()
return true
elseif (Name == "blockos_apps") then
if (Tab ~= 3) then Build() end
return true
end
return false
end
os.queueEvent("appstore_refresh")
Page:Run()
]=]
Files["blockos/apps/brocreate/app.cfg"] = [[
{ Name = "Brocreate", Description = "Paint pictures with colours and text characters, and set them as your wallpaper.", Width = 48, Height = 16, Icon = { Lines = { "\7 \7", " \7/" }, Fg = "white", Bg = "magenta" } }
]]
Files["blockos/apps/brocreate/main.lua"] = [[
-- Brocreate: paint pictures with solid colours and text characters, save them and set them as the wallpaper.
local Ui = BlockOS.Require("ui")
local Gfx = BlockOS.Require("gfx")
local Config = BlockOS.Require("config")
local Image = BlockOS.Require("image")
local Wallpaper = BlockOS.Require("wallpaper")
local Args = { ... }
local Folder = "/home/Pictures"
local MaxUndo = 30
local MaxSide = 164
local Tools = {
{ ID = "pen", Label = "Pen", Short = "Pn" },
{ ID = "line", Label = "Line", Short = "Ln" },
{ ID = "box", Label = "Box", Short = "Bx" },
{ ID = "fill", Label = "Fill", Short = "Fl" },
{ ID = "text", Label = "Text", Short = "Tx" },
{ ID = "pick", Label = "Pick", Short = "Pk" },
{ ID = "erase", Label = "Erase", Short = "Er" }
}
-- Palette entries that need dark marks on top.
local Light = { ["0"] = true, ["1"] = true, ["3"] = true, ["4"] = true, ["5"] = true, ["6"] = true, ["8"] = true, ["9"] = true }
local Page = Ui.Page.new()
local State = { Tool = "pen", Ink = "solid", Char = "#", Primary = "f", Secondary = "0", Filled = false }
local View = { ScrollX = 0, ScrollY = 0 }
local Layout = { CX = 1, CY = 2, CW = 1, CH = 1, Swatch = 2 }
local Doc, Stroke, Caret, Hover, CanvasWidget
local Undo, Redo = {}, {}
--
-- Document
--
local function FromImage(Img)
local NewDoc = { W = Img.W, H = Img.H, Text = {}, Fg = {}, Bg = {}, Base = Img.Rows[1][3]:sub(1, 1) }
for Y = 1, Img.H do
local Row = Img.Rows[Y]
local Text, Fg, Bg = {}, {}, {}
for X = 1, Img.W do
Text[X] = Row[1]:sub(X, X)
Fg[X] = Row[2]:sub(X, X)
Bg[X] = Row[3]:sub(X, X)
end
NewDoc.Text[Y], NewDoc.Fg[Y], NewDoc.Bg[Y] = Text, Fg, Bg
end
return NewDoc
end
local function ToImage()
local Rows = {}
for Y = 1, Doc.H do
Rows[Y] = { table.concat(Doc.Text[Y]), table.concat(Doc.Fg[Y]), table.concat(Doc.Bg[Y]) }
end
return { W = Doc.W, H = Doc.H, Rows = Rows }
end
local function DisplayName()
return Doc.Path and fs.getName(Doc.Path) or "Untitled"
end
local function UpdateTitle()
BlockOS.SetTitle(DisplayName() .. (Doc.Dirty and " *" or ""))
end
local function MarkDirty()
if (Doc.Dirty) then return end
Doc.Dirty = true
UpdateTitle()
end
local function ClampScroll()
View.ScrollX = math.max(0, math.min(View.ScrollX, Doc.W - Layout.CW))
View.ScrollY = math.max(0, math.min(View.ScrollY, Doc.H - Layout.CH))
end
local function SetDoc(NewDoc, Path)
Doc = NewDoc
Doc.Path = Path
Doc.Dirty = false
Undo, Redo = {}, {}
View.ScrollX, View.ScrollY = 0, 0
Stroke, Caret, Hover = nil, nil, nil
UpdateTitle()
Page:Invalidate()
end
local function PushUndo()
table.insert(Undo, ToImage())
if (#Undo > MaxUndo) then table.remove(Undo, 1) end
Redo = {}
end
local function Restore(Snapshot)
local Restored = FromImage(Snapshot)
Restored.Path, Restored.Base = Doc.Path, Doc.Base
Doc = Restored
ClampScroll()
MarkDirty()
end
local function DoUndo()
if (#Undo == 0) then return end
table.insert(Redo, ToImage())
Restore(table.remove(Undo))
end
local function DoRedo()
if (#Redo == 0) then return end
table.insert(Undo, ToImage())
Restore(table.remove(Redo))
end
local function HasCharacters()
for Y = 1, Doc.H do
for X = 1, Doc.W do
if (Doc.Text[Y][X] ~= " ") then return true end
end
end
return false
end
--
-- Painting
--
local function InBounds(X, Y)
return X >= 1 and Y >= 1 and X <= Doc.W and Y <= Doc.H
end
-- What a cell becomes under the current ink (solid colour, character or eraser).
local function Inked(X, Y, Color)
if (State.Tool == "erase") then
return " ", "f", Doc.Base
elseif (State.Ink == "solid") then
return " ", Doc.Fg[Y][X], Color
end
return State.Char, Color, Doc.Bg[Y][X]
end
local function Plot(X, Y, Color)
if (not InBounds(X, Y)) then return end
Doc.Text[Y][X], Doc.Fg[Y][X], Doc.Bg[Y][X] = Inked(X, Y, Color)
end
local function LinePoints(X1, Y1, X2, Y2)
local Points = {}
local DX, DY = math.abs(X2 - X1), -math.abs(Y2 - Y1)
local StepX = (X1 < X2) and 1 or -1
local StepY = (Y1 < Y2) and 1 or -1
local Err = DX + DY
while (true) do
table.insert(Points, { X1, Y1 })
if (X1 == X2 and Y1 == Y2) then break end
local Double = 2 * Err
if (Double >= DY) then
Err = Err + DY
X1 = X1 + StepX
end
if (Double <= DX) then
Err = Err + DX
Y1 = Y1 + StepY
end
end
return Points
end
local function BoxPoints(X1, Y1, X2, Y2)
local Points = {}
local Left, Right = math.max(1, math.min(X1, X2)), math.min(Doc.W, math.max(X1, X2))
local Top, Bottom = math.max(1, math.min(Y1, Y2)), math.min(Doc.H, math.max(Y1, Y2))
local EdgeLeft, EdgeRight = math.min(X1, X2), math.max(X1, X2)
local EdgeTop, EdgeBottom = math.min(Y1, Y2), math.max(Y1, Y2)
for Y = Top, Bottom do
for X = Left, Right do
if (State.Filled or Y == EdgeTop or Y == EdgeBottom or X == EdgeLeft or X == EdgeRight) then
table.insert(Points, { X, Y })
end
end
end
return Points
end
local function ShapePoints(Shape)
if (State.Tool == "line") then
return LinePoints(Shape.X1, Shape.Y1, Shape.X2, Shape.Y2)
end
return BoxPoints(Shape.X1, Shape.Y1, Shape.X2, Shape.Y2)
end
-- Solid ink fills the area of matching background and keeps characters; character ink fills matching cells.
local function Fill(StartX, StartY, Color)
if (not InBounds(StartX, StartY)) then return false end
local Solid = State.Ink == "solid"
local function KeyOf(X, Y)
if (Solid) then return Doc.Bg[Y][X] end
return Doc.Text[Y][X] .. Doc.Fg[Y][X] .. Doc.Bg[Y][X]
end
local Target = KeyOf(StartX, StartY)
local Seen = {}
local Stack = { { StartX, StartY } }
while (#Stack > 0) do
local Point = table.remove(Stack)
local X, Y = Point[1], Point[2]
local Index = Y * (Doc.W + 1) + X
if (InBounds(X, Y) and not Seen[Index] and KeyOf(X, Y) == Target) then
Seen[Index] = true
if (Solid) then
Doc.Bg[Y][X] = Color
else
Doc.Text[Y][X], Doc.Fg[Y][X] = State.Char, Color
end
table.insert(Stack, { X + 1, Y })
table.insert(Stack, { X - 1, Y })
table.insert(Stack, { X, Y + 1 })
table.insert(Stack, { X, Y - 1 })
end
end
return true
end
local function Pick(X, Y, Button)
if (not InBounds(X, Y)) then return end
if (Button == 2) then
State.Secondary = Doc.Bg[Y][X]
elseif (State.Ink == "char" and Doc.Text[Y][X] ~= " ") then
State.Primary, State.Char = Doc.Fg[Y][X], Doc.Text[Y][X]
else
State.Primary = Doc.Bg[Y][X]
end
end
--
-- Canvas view
--
-- Page position of image cell 1, 1. Small images are centred, large ones scroll.
local function Origin()
local OffsetX = (Doc.W <= Layout.CW) and math.floor((Layout.CW - Doc.W) / 2) or -View.ScrollX
local OffsetY = (Doc.H <= Layout.CH) and math.floor((Layout.CH - Doc.H) / 2) or -View.ScrollY
return Layout.CX + OffsetX, Layout.CY + OffsetY
end
local function ToCell(PageX, PageY)
local OriginX, OriginY = Origin()
return PageX - OriginX + 1, PageY - OriginY + 1
end
local function Pan(DX, DY)
View.ScrollX = View.ScrollX + DX
View.ScrollY = View.ScrollY + DY
ClampScroll()
end
local function BeginStroke(Button, PageX, PageY)
local X, Y = ToCell(PageX, PageY)
Hover = { X, Y }
Caret = nil
if (Button == 3) then
Stroke = { Pan = true, LastX = PageX, LastY = PageY }
return true
end
local Color = (Button == 2) and State.Secondary or State.Primary
local Tool = State.Tool
if (Tool == "pen" or Tool == "erase") then
PushUndo()
Stroke = { LastX = X, LastY = Y, Color = Color }
Plot(X, Y, Color)
MarkDirty()
elseif (Tool == "line" or Tool == "box") then
Stroke = { Shape = true, X1 = X, Y1 = Y, X2 = X, Y2 = Y, Color = Color }
elseif (Tool == "fill" and InBounds(X, Y)) then
PushUndo()
Fill(X, Y, Color)
MarkDirty()
elseif (Tool == "pick") then
Pick(X, Y, Button)
elseif (Tool == "text" and InBounds(X, Y)) then
Caret = { X = X, Y = Y, StartX = X, Color = Color }
end
return true
end
local function ContinueStroke(PageX, PageY)
if (not Stroke) then return false end
if (Stroke.Pan) then
Pan(Stroke.LastX - PageX, Stroke.LastY - PageY)
Stroke.LastX, Stroke.LastY = PageX, PageY
return true
end
local X, Y = ToCell(PageX, PageY)
Hover = { X, Y }
if (Stroke.Shape) then
Stroke.X2, Stroke.Y2 = X, Y
return true
end
for _, Point in ipairs(LinePoints(Stroke.LastX, Stroke.LastY, X, Y)) do
Plot(Point[1], Point[2], Stroke.Color)
end
Stroke.LastX, Stroke.LastY = X, Y
return true
end
local function EndStroke()
if (Stroke and Stroke.Shape) then
PushUndo()
for _, Point in ipairs(ShapePoints(Stroke)) do
Plot(Point[1], Point[2], Stroke.Color)
end
MarkDirty()
end
Stroke = nil
return true
end
-- Text tool: the whole typing run since the click is one undo step.
local function TypeText(Text)
if (not Caret) then return false end
if (not Caret.Pushed) then
PushUndo()
Caret.Pushed = true
end
for Index = 1, #Text do
if (InBounds(Caret.X, Caret.Y)) then
Doc.Text[Caret.Y][Caret.X] = Text:sub(Index, Index)
Doc.Fg[Caret.Y][Caret.X] = Caret.Color
end
Caret.X = math.min(Caret.X + 1, Doc.W + 1)
end
MarkDirty()
return true
end
local function CaretKey(Key)
if (Key == keys.enter or Key == keys.numPadEnter) then
Caret.X = Caret.StartX
Caret.Y = math.min(Caret.Y + 1, Doc.H)
elseif (Key == keys.backspace) then
if (Caret.X <= 1) then return true end
Caret.X = Caret.X - 1
TypeText(" ")
Caret.X = Caret.X - 1
elseif (Key == keys.left or Key == keys.right) then
Caret.X = math.max(1, math.min(Doc.W, Caret.X + ((Key == keys.left) and -1 or 1)))
elseif (Key == keys.up or Key == keys.down) then
Caret.Y = math.max(1, math.min(Doc.H, Caret.Y + ((Key == keys.up) and -1 or 1)))
else
return false
end
return true
end
local function CanvasKey(Key)
if (Ui.Modifiers.Ctrl) then return false end
if (Caret) then return CaretKey(Key) end
local Step = Ui.Modifiers.Shift and 8 or 1
if (Key == keys.left) then
Pan(-Step, 0)
elseif (Key == keys.right) then
Pan(Step, 0)
elseif (Key == keys.up) then
Pan(0, -Step)
elseif (Key == keys.down) then
Pan(0, Step)
else
return false
end
return true
end
local function DrawCanvas(Self, T, Th)
Gfx.Fill(T, Self.X, Self.Y, Self.W, Self.H, Th.Panel, Th.SubText, "\127")
local OriginX, OriginY = Origin()
local First = math.max(1, Self.X - OriginX + 1)
local Last = math.min(Doc.W, Self.X + Self.W - OriginX)
if (First > Last) then return end
for Y = math.max(1, Self.Y - OriginY + 1), math.min(Doc.H, Self.Y + Self.H - OriginY) do
T.setCursorPos(OriginX + First - 1, OriginY + Y - 1)
T.blit(
table.concat(Doc.Text[Y], "", First, Last),
table.concat(Doc.Fg[Y], "", First, Last),
table.concat(Doc.Bg[Y], "", First, Last)
)
end
if (Stroke and Stroke.Shape) then
for _, Point in ipairs(ShapePoints(Stroke)) do
local X, Y = Point[1], Point[2]
local ScreenX, ScreenY = OriginX + X - 1, OriginY + Y - 1
if (InBounds(X, Y) and Ui.Inside(Self, ScreenX, ScreenY)) then
T.setCursorPos(ScreenX, ScreenY)
T.blit(Inked(X, Y, Stroke.Color))
end
end
end
end
local function CaretPos(Self)
if (not Caret) then return nil end
local OriginX, OriginY = Origin()
local X, Y = OriginX + Caret.X - 1, OriginY + Caret.Y - 1
if (not Ui.Inside(Self, X, Y)) then return nil end
local Under = InBounds(Caret.X, Caret.Y) and Doc.Bg[Caret.Y][Caret.X] or Doc.Base
local Color = Caret.Color
if (Color == Under) then Color = Light[Under] and "f" or "0" end
return X, Y, 2 ^ tonumber(Color, 16)
end
--
-- Files
--
local function ParseSize(Answer)
if (not Answer) then return nil end
local W, H = Answer:match("^%s*(%d+)%s*[xX*, ]%s*(%d+)%s*$")
W, H = tonumber(W), tonumber(H)
if (not W or W < 1 or H < 1 or W > MaxSide or H > MaxSide) then
Ui.Alert("Canvas Size", "Enter a size like 51x19, up to " .. MaxSide .. " on each side.")
return nil
end
return W, H
end
local function DefaultSize()
if (BlockOS.ScreenSize) then return BlockOS.ScreenSize() end
return term.getSize()
end
local function BlankDoc(W, H, Base)
return FromImage(Image.new(W, H, Base))
end
local function SaveTo(Path)
if (Path:lower():match("%.nfp$") and HasCharacters()) then
if (not Ui.Confirm("Save as .nfp", ".nfp pictures only keep colours, so characters will be lost.", "Save")) then return false end
end
if (not Image.Save(Path, ToImage(), DisplayName())) then
Ui.Alert("Save Failed", "Could not write " .. Path)
return false
end
Doc.Path = Path
Doc.Dirty = false
UpdateTitle()
local Settings = Config.Settings()
if (Settings.Wallpaper == "picture" and Settings.WallpaperImage == Path) then
Wallpaper.Invalidate()
os.queueEvent("blockos_settings")
end
return true
end
local function SaveAs()
if (not fs.exists(Folder)) then fs.makeDir(Folder) end
local Path = Ui.PickFile({
Title = "Save Picture", Save = true,
Dir = Doc.Path and fs.getDir(Doc.Path) or Folder,
Name = Doc.Path and fs.getName(Doc.Path) or "Untitled.bimg"
})
if (not Path) then return false end
if (not Image.IsImage(Path)) then Path = Path .. ".bimg" end
if (fs.isDir(Path) or fs.isReadOnly(Path)) then
Ui.Alert("Save Picture", "Can't save to " .. Path)
return false
end
if (fs.exists(Path) and Path ~= Doc.Path) then
if (not Ui.Confirm("Replace", "\"" .. fs.getName(Path) .. "\" already exists. Replace it?", "Replace")) then return false end
end
return SaveTo(Path)
end
local function Save()
if (Doc.Path) then return SaveTo(Doc.Path) end
return SaveAs()
end
-- Returns true when it is fine to throw the current picture away.
local function ConfirmDiscard()
if (not Doc.Dirty) then return true end
local Choice = Ui.Alert("Unsaved Changes", "Save changes to \"" .. DisplayName() .. "\"?", { "Cancel", "Don't Save", "Save" })
if (Choice == 3) then return Save() end
return Choice == 2
end
local function OpenPath(Path)
local Img, Err = Image.Load(Path)
if (not Img) then
Ui.Alert("Open Picture", fs.getName(Path) .. ": " .. tostring(Err))
return false
end
SetDoc(FromImage(Img), Path)
return true
end
local function OpenPicture()
if (not ConfirmDiscard()) then return end
local Path = Ui.PickFile({ Title = "Open Picture", Dir = fs.exists(Folder) and Folder or "home" })
if (Path) then OpenPath(Path) end
end
local function NewPicture()
if (not ConfirmDiscard()) then return end
local ScreenW, ScreenH = DefaultSize()
local W, H = ParseSize(Ui.Prompt("New Picture", "Width x height. The background uses your right-click colour.", ScreenW .. "x" .. ScreenH))
if (not W) then return end
local NewDoc = BlankDoc(W, H, State.Secondary)
SetDoc(NewDoc, nil)
end
local function ResizeCanvas()
local W, H = ParseSize(Ui.Prompt("Canvas Size", "Width x height. New space uses the background colour.", Doc.W .. "x" .. Doc.H))
if (not W) then return end
PushUndo()
local Resized = BlankDoc(W, H, Doc.Base)
for Y = 1, math.min(H, Doc.H) do
for X = 1, math.min(W, Doc.W) do
Resized.Text[Y][X], Resized.Fg[Y][X], Resized.Bg[Y][X] = Doc.Text[Y][X], Doc.Fg[Y][X], Doc.Bg[Y][X]
end
end
Resized.Path, Resized.Base = Doc.Path, Doc.Base
Doc = Resized
Caret = nil
ClampScroll()
MarkDirty()
end
local function ClearCanvas()
PushUndo()
local Path, Base = Doc.Path, Doc.Base
Doc = BlankDoc(Doc.W, Doc.H, Base)
Doc.Path = Path
Caret = nil
MarkDirty()
end
local function SetAsWallpaper()
if ((Doc.Dirty or not Doc.Path) and not Save()) then return end
local Settings = Config.Settings()
Settings.Wallpaper = "picture"
Settings.WallpaperImage = Doc.Path
Wallpaper.Invalidate()
Config.Save("settings")
BlockOS.Notify("Brocreate", "Wallpaper set to " .. DisplayName())
end
local function ChooseChar()
local Code = Ui.PickChar("Character", State.Char:byte())
if (Code) then
State.Char = string.char(Code)
State.Ink = "char"
end
end
--
-- Layout
--
local function FileMenu(Self)
local Options = { "New...", "Open...", "Save", "Save As...", "-", "Set as Wallpaper", "-", "Canvas Size..." }
local Choice = Options[Ui.ContextMenu(Self.X, Self.Y + 1, Options) or 0]
if (Choice == "New...") then
NewPicture()
elseif (Choice == "Open...") then
OpenPicture()
elseif (Choice == "Save") then
Save()
elseif (Choice == "Save As...") then
SaveAs()
elseif (Choice == "Set as Wallpaper") then
SetAsWallpaper()
elseif (Choice == "Canvas Size...") then
ResizeCanvas()
end
Page:Invalidate()
end
local function EditMenu(Self)
local Options = {
{ Text = "Undo", Disabled = #Undo == 0 },
{ Text = "Redo", Disabled = #Redo == 0 },
"-",
{ Text = State.Filled and "Shapes: Filled" or "Shapes: Outline" },
{ Text = "Choose Character..." },
"-",
{ Text = "Clear Canvas" }
}
local Index = Ui.ContextMenu(Self.X, Self.Y + 1, Options)
local Choice = Index and Options[Index].Text
if (Choice == "Undo") then
DoUndo()
elseif (Choice == "Redo") then
DoRedo()
elseif (Choice == "Shapes: Filled" or Choice == "Shapes: Outline") then
State.Filled = not State.Filled
elseif (Choice == "Choose Character...") then
ChooseChar()
elseif (Choice == "Clear Canvas") then
ClearCanvas()
end
Page:Invalidate()
end
local function ToolColors(Self, Th)
if (State.Tool == Self.Tool) then return Th.Accent, Th.AccentFg end
return Th.Panel, Th.Text
end
local function InkColors(Self, Th)
if (State.Ink == Self.Ink) then return Th.Accent, Th.AccentFg end
return Th.ButtonBg, Th.ButtonFg
end
local function StatusText()
local Size = Doc.W .. "x" .. Doc.H
if (Caret) then return "Typing \7 " .. Size end
if (Hover and InBounds(Hover[1], Hover[2])) then
return Hover[1] .. "," .. Hover[2] .. " \7 " .. Size
end
return Size
end
local function DrawPalette(Self, T, Th)
Gfx.Fill(T, Self.X, Self.Y, Self.W, 1, Th.Panel)
local Swatch = Layout.Swatch
for Index = 0, 15 do
local Hex = string.format("%x", Index)
local Marks = (Hex == State.Primary and "L" or " ") .. (Hex == State.Secondary and "R" or " ")
if (Swatch == 1) then
Marks = (Hex == State.Primary and "L") or (Hex == State.Secondary and "R") or " "
end
T.setCursorPos(Self.X + Index * Swatch, Self.Y)
T.blit(Marks, string.rep(Light[Hex] and "f" or "0", Swatch), string.rep(Hex, Swatch))
end
end
local function Build()
Page:Clear()
local W, H = Page.W, Page.H
Layout.CX, Layout.CY = 1, 2
Layout.CW, Layout.CH = W, math.max(1, H - 3)
Layout.Swatch = (W >= 34) and 2 or 1
ClampScroll()
Page:Add(Ui.Panel.new({ X = 1, Y = 1, W = W, H = 1 }))
Page:Add(Ui.Button.new({ X = 1, Y = 1, Text = " File ", Style = "plain", Bg = "Panel", Fg = "Text", KeepFocus = true, OnClick = FileMenu }))
Page:Add(Ui.Button.new({ X = 7, Y = 1, Text = " Edit ", Style = "plain", Bg = "Panel", Fg = "Text", KeepFocus = true, OnClick = EditMenu }))
local ToolsW = 0
for _, Tool in ipairs(Tools) do
ToolsW = ToolsW + #Tool.Label + 1
end
local Wide = 13 + ToolsW <= W
local X = 14
for _, Tool in ipairs(Tools) do
local Label = " " .. (Wide and Tool.Label or Tool.Short)
local Button = Page:Add(Ui.Button.new({
X = X, Y = 1, W = #Label, Text = Label, Tool = Tool.ID, KeepFocus = true,
OnClick = function(Self)
State.Tool = Self.Tool
Caret, Stroke = nil, nil
end
}))
Button.Colors = ToolColors
X = X + #Label
end
CanvasWidget = Page:Add(Ui.Canvas.new({
X = Layout.CX, Y = Layout.CY, W = Layout.CW, H = Layout.CH, Focusable = true, KeepFocus = true,
OnDraw = DrawCanvas,
OnCursor = CaretPos,
OnEvent = function(Self, Name, A, B, C)
if (Name == "mouse_click") then
return BeginStroke(A, B, C)
elseif (Name == "mouse_drag") then
return ContinueStroke(B, C)
elseif (Name == "mouse_up") then
return EndStroke()
elseif (Name == "mouse_scroll") then
if (Ui.Modifiers.Shift) then
Pan(A * 2, 0)
else
Pan(0, A)
end
return true
elseif (Name == "char") then
if (Caret) then return TypeText(A) end
if (State.Ink == "char" and State.Tool ~= "text") then
State.Char = A
return true
end
return false
elseif (Name == "paste") then
return TypeText((A:gsub("[\r\n]", " ")))
elseif (Name == "key") then
return CanvasKey(A)
end
return false
end
}))
Page:Add(Ui.Canvas.new({
X = 1, Y = H - 1, W = W, H = 1, KeepFocus = true,
OnDraw = DrawPalette,
OnEvent = function(Self, Name, A, B)
if (Name ~= "mouse_click") then return false end
local Index = math.floor((B - Self.X) / Layout.Swatch)
if (Index < 0 or Index > 15) then return true end
if (A == 2) then
State.Secondary = string.format("%x", Index)
else
State.Primary = string.format("%x", Index)
end
return true
end
}))
Page:Add(Ui.Panel.new({ X = 1, Y = H, W = W, H = 1 }))
local SolidButton = Page:Add(Ui.Button.new({
X = 1, Y = H, Text = " Solid ", Ink = "solid", KeepFocus = true,
OnClick = function()
State.Ink = "solid"
end
}))
SolidButton.Colors = InkColors
local CharButton = Page:Add(Ui.Button.new({
X = 9, Y = H, W = 8, Ink = "char", KeepFocus = true,
Text = function()
return " Char " .. State.Char .. " "
end,
OnClick = function(_, Button)
if (State.Ink == "char" or Button == 2) then
ChooseChar()
end
State.Ink = "char"
end
}))
CharButton.Colors = InkColors
if (W >= 20) then
Page:Add(Ui.Label.new({ X = 18, Y = H, W = W - 18, Align = "right", Fg = "SubText", Bg = "Panel", Text = StatusText }))
end
Page:SetFocus(CanvasWidget)
Page:Invalidate()
end
function Page:OnLayout()
Build()
end
function Page:OnEvent(Name, A)
if (Name == "blockos_close") then
if (ConfirmDiscard()) then self:Stop() end
return true
elseif (Name == "key" and Ui.Modifiers.Ctrl) then
if (A == keys.s) then
Save()
elseif (A == keys.o) then
OpenPicture()
elseif (A == keys.n) then
NewPicture()
elseif (A == keys.z) then
DoUndo()
elseif (A == keys.y) then
DoRedo()
else
return false
end
return true
end
return false
end
function Page:OnTerminate()
if (ConfirmDiscard()) then self:Stop() end
end
BlockOS.SetCloseHandler(true)
local Target = Args[1] and (shell and shell.resolve(Args[1]) or Args[1])
if (Target and not Target:find("^/")) then Target = "/" .. Target end
local ScreenW, ScreenH = DefaultSize()
SetDoc(BlankDoc(ScreenW, ScreenH, State.Secondary), nil)
if (Target and fs.exists(Target) and not fs.isDir(Target)) then
OpenPath(Target)
end
Page:Run()
]]
Files["blockos/apps/btunes/app.cfg"] = [[
{ Name = "bTunes", Description = "Play YouTube, DFPWM links and music files through your speakers.", Width = 40, Height = 14, Single = true, Icon = { Lines = { " \14 ", "\140\140\140" }, Fg = "white", Bg = "red" } }
]]
Files["blockos/apps/btunes/main.lua"] = [[
-- bTunes: plays YouTube, DFPWM links and files on disk through every attached speaker.
local Ui = BlockOS.Require("ui")
local Gfx = BlockOS.Require("gfx")
local Config = BlockOS.Require("config")
local Dfpwm = require("cc.audio.dfpwm")
local Args = { ... }
local LibraryPath = "/blockos/data/music.dat"
local ChunkSize = 16 * 1024
local BytesPerSecond = 6000
local SampleRate = 48000
-- Community server that converts YouTube audio to DFPWM (the same one Minux and iPod use).
local DefaultServer = "https://ipod-2to6magyna-uc.a.run.app/"
local ApiVersion = "2.1"
local Library = Config.Read(LibraryPath) or { Tracks = {} }
local Page = Ui.Page.new()
local Tab = 1
local Build
local SearchField
local Search = {
Query = "",
Results = {},
State = "idle",
RequestUrl = nil,
Error = nil
}
-- Title lookups for pasted YouTube links, keyed by request URL.
local Lookups = {}
local Player = {
Queue = {},
Index = 0,
State = "stopped",
Volume = 1,
Shuffle = false,
Repeat = false,
Bytes = 0,
Total = 0,
-- Decoded chunks with the clock times the speakers play them, newest last.
Timeline = {},
AudioEnd = nil,
Error = nil
}
local Visual = {
Modes = { "Bars", "Mirror", "Wave" },
Window = 1024,
Count = 0,
Wanted = 16,
-- FFT tables, built once.
Hann = {},
Reverse = {},
Cos = {},
Sin = {},
-- First FFT bin of each band; band N ends where band N + 1 starts.
Edges = {},
Bands = {},
Peaks = {},
Wave = nil
}
local function Speakers()
local List = {}
for _, Name in ipairs(peripheral.getNames()) do
if (peripheral.getType(Name) == "speaker") then
table.insert(List, { Name = Name, Device = peripheral.wrap(Name) })
end
end
return List
end
local function SaveLibrary()
Config.Write(LibraryPath, Library)
end
local function Server()
local Url = Library.Server or DefaultServer
if (Url:sub(-1) ~= "/") then Url = Url .. "/" end
return Url
end
local function StreamUrl(ID)
return Server() .. "?v=" .. ApiVersion .. "&id=" .. textutils.urlEncode(ID)
end
local function SearchUrl(Query)
return Server() .. "?v=" .. ApiVersion .. "&search=" .. textutils.urlEncode(Query)
end
-- Returns the video ID of a YouTube link, or nil for anything else.
local function YouTubeID(Link)
if (not Link:match("youtube%.com") and not Link:match("youtu%.be")) then return nil end
return Link:match("youtu%.be/([%w_%-]+)") or Link:match("[?&]v=([%w_%-]+)") or Link:match("/shorts/([%w_%-]+)") or Link:match("/live/([%w_%-]+)")
end
local function IsYouTubePlaylist(Link)
return (Link:match("youtube%.com") and Link:match("[?&]list=") and not YouTubeID(Link)) and true or false
end
local function FormatTime(Seconds)
Seconds = math.max(0, math.floor(Seconds))
return string.format("%d:%02d", math.floor(Seconds / 60), Seconds % 60)
end
local function CloseHandle()
if (Player.Handle) then
pcall(Player.Handle.close)
Player.Handle = nil
end
end
local function Stop()
Player.State = "stopped"
Player.Buffer = nil
Player.Pending = nil
Player.RequestUrl = nil
Player.Timeline = {}
Player.AudioEnd = nil
CloseHandle()
for _, Speaker in ipairs(Speakers()) do
pcall(Speaker.Device.stop)
end
end
local Play
local function TrackEnded()
CloseHandle()
Player.Buffer = nil
local Next = Player.Index + 1
if (Player.Shuffle and #Player.Queue > 1) then
repeat
Next = math.random(1, #Player.Queue)
until Next ~= Player.Index
end
if (Next > #Player.Queue) then
if (not Player.Repeat) then
Player.State = "stopped"
return
end
Next = 1
end
Play(Next)
end
-- Remembers when a chunk the speakers just accepted will be heard, so the visualizer follows the sound.
local function Schedule(Samples)
local Start = math.max(os.clock(), Player.AudioEnd or 0)
Player.AudioEnd = Start + #Samples / SampleRate
table.insert(Player.Timeline, { Samples = Samples, Start = Start, End = Player.AudioEnd })
while (#Player.Timeline > 3) do
table.remove(Player.Timeline, 1)
end
end
-- Pushes decoded audio to speakers until one of them is full.
local function Feed()
while (Player.State == "playing") do
if (not Player.Buffer) then
local Chunk = Player.Handle and Player.Handle.read(ChunkSize)
if (not Chunk or #Chunk == 0) then
TrackEnded()
return
end
Player.Bytes = Player.Bytes + #Chunk
Player.Buffer = Player.Decoder(Chunk)
Player.Pending = {}
for _, Speaker in ipairs(Speakers()) do
Player.Pending[Speaker.Name] = Speaker.Device
end
if (next(Player.Pending) == nil) then
Player.Error = "No speaker attached"
Stop()
return
end
end
for Name, Device in pairs(Player.Pending) do
if (Device.playAudio(Player.Buffer, Player.Volume)) then
Player.Pending[Name] = nil
end
end
if (next(Player.Pending) ~= nil) then return end
Schedule(Player.Buffer)
Player.Buffer = nil
end
end
local function StartStream(Handle, Total)
Player.Handle = Handle
Player.Total = Total or 0
Player.Bytes = 0
Player.Decoder = Dfpwm.make_decoder()
Player.State = "playing"
Player.Error = nil
Feed()
end
function Play(Index)
Stop()
local Track = Player.Queue[Index]
if (not Track) then return end
Player.Index = Index
Player.Error = nil
BlockOS.SetTitle("bTunes \7 " .. Track.Name)
if (Track.Path) then
local Handle = fs.open(Track.Path, "rb")
if (not Handle) then
Player.Error = "Cannot open " .. Track.Path
return
end
StartStream(Handle, fs.getSize(Track.Path))
return
end
if (not http) then
Player.Error = "HTTP is disabled"
return
end
local Url = Track.ID and StreamUrl(Track.ID) or Track.Url
Player.State = "loading"
Player.RequestUrl = Url
http.request({ url = Url, binary = true })
end
local function PlayList(Tracks, Start)
Player.Queue = Tracks
Play(Start or 1)
end
local function TogglePause()
if (Player.State == "playing") then
Player.State = "paused"
elseif (Player.State == "paused") then
Player.State = "playing"
Feed()
elseif (Player.State == "stopped" and #Player.Queue > 0) then
Play(math.max(1, Player.Index))
end
end
local function Previous()
if (#Player.Queue == 0) then return end
if (Player.Bytes > BytesPerSecond * 3) then
Play(Player.Index)
else
Play(math.max(1, Player.Index - 1))
end
end
local function ResultTrack(Item)
return { Name = tostring(Item.name or "Untitled"), Artist = Item.artist and tostring(Item.artist) or nil, ID = tostring(Item.id) }
end
local function RunSearch(Query)
Query = (Query or ""):gsub("^%s+", ""):gsub("%s+$", "")
if (#Query == 0) then return end
if (not http) then
Search.Error = "HTTP is disabled"
Build()
return
end
Search.Query = Query
Search.Results = {}
Search.Error = nil
Search.State = "searching"
Search.RequestUrl = SearchUrl(Query)
http.request(Search.RequestUrl)
Build()
end
local function SearchDone(Handle)
local Body = Handle.readAll()
pcall(Handle.close)
Search.State = "idle"
local Parsed, Results = pcall(textutils.unserialiseJSON, Body or "")
if (not Parsed or type(Results) ~= "table") then
Search.Error = "The YouTube server sent an unexpected reply"
return
end
Search.Results = {}
for _, Item in ipairs(Results) do
if (type(Item) == "table" and Item.id) then
local Entry = { Name = tostring(Item.name or "Untitled"), Artist = Item.artist and tostring(Item.artist) or nil }
if (Item.type == "playlist" and type(Item.playlist_items) == "table") then
Entry.Tracks = {}
for _, Song in ipairs(Item.playlist_items) do
if (type(Song) == "table" and Song.id) then table.insert(Entry.Tracks, ResultTrack(Song)) end
end
else
Entry.ID = tostring(Item.id)
end
table.insert(Search.Results, Entry)
end
end
if (#Search.Results == 0) then Search.Error = "No results for \"" .. Search.Query .. "\"" end
end
-- Asks the server for the real title of a pasted YouTube link.
local function LookupTitle(Track)
if (not http) then return end
local Url = SearchUrl("https://youtu.be/" .. Track.ID)
Lookups[Url] = Track
http.request(Url)
end
local function LookupDone(Track, Handle)
local Body = Handle.readAll()
pcall(Handle.close)
local Parsed, Results = pcall(textutils.unserialiseJSON, Body or "")
if (not Parsed or type(Results) ~= "table" or type(Results[1]) ~= "table") then return end
local Match = Results[1]
for _, Item in ipairs(Results) do
if (type(Item) == "table" and tostring(Item.id) == Track.ID) then Match = Item end
end
if (not Match.name) then return end
Track.Name = tostring(Match.name)
Track.Artist = Match.artist and tostring(Match.artist) or nil
SaveLibrary()
end
-- The tracks a search result stands for: one song, or every song of a playlist.
local function ResultTracks(Entry)
if (Entry.Tracks) then return Entry.Tracks end
return { { Name = Entry.Name, Artist = Entry.Artist, ID = Entry.ID } }
end
local function LocalFiles()
local Files = {}
local Folders = { "/home/Music", "/home/Downloads" }
for _, Name in ipairs(peripheral.getNames()) do
if (peripheral.getType(Name) == "drive" and disk.hasData(Name)) then
table.insert(Folders, "/" .. disk.getMountPath(Name))
end
end
for _, Folder in ipairs(Folders) do
if (fs.isDir(Folder)) then
for _, Name in ipairs(fs.list(Folder)) do
if (Name:lower():match("%.dfpwm$")) then
table.insert(Files, { Name = Name:gsub("%.[Dd][Ff][Pp][Ww][Mm]$", ""), Path = fs.combine(Folder, Name), Where = Folder })
end
end
end
end
return Files
end
local function TrackList(Y, H, Tracks, Removable)
local Items = {}
for Index, Track in ipairs(Tracks) do
local Playing = Player.Queue[Player.Index] == Track and Player.State ~= "stopped"
local Source = Track.Path and "disk" or (Track.ID and "yt" or "web")
table.insert(Items, { Text = Track.Name, Icon = Playing and "\16" or "\14", IconFg = Playing and "Accent" or "SubText", Right = Source, Index = Index })
end
local List = Page:Add(Ui.List.new({ X = 1, Y = Y, W = Page.W, H = H, Items = Items }))
List.OnActivate = function(_, Index) PlayList(Tracks, Index) end
List.OnContext = function(_, Index, Item, X, CY)
if (not Item) then return end
local Options = { "Play", "Play Next" }
if (Removable) then
table.insert(Options, "Rename")
table.insert(Options, "Remove")
end
local Choice = Options[Ui.ContextMenu(X, CY, Options) or 0]
if (Choice == "Play") then
PlayList(Tracks, Index)
elseif (Choice == "Play Next") then
table.insert(Player.Queue, math.min(#Player.Queue + 1, Player.Index + 1), Tracks[Index])
elseif (Choice == "Rename") then
local Name = Ui.Prompt("Rename", "Track name:", Tracks[Index].Name)
if (Name and #Name > 0) then
Tracks[Index].Name = Name
SaveLibrary()
Build()
end
elseif (Choice == "Remove") then
table.remove(Tracks, Index)
SaveLibrary()
Build()
end
end
if (#Items == 0) then
Page:Add(Ui.Label.new({ X = 2, Y = Y + 1, W = Page.W - 2, H = 2, Fg = "SubText",
Text = Removable and "Your library is empty. Find songs in Search or add a link in Add." or "No .dfpwm files in Music, Downloads or disks." }))
end
end
local function BuildSearch(Y, H)
local W = Page.W
SearchField = Page:Add(Ui.Input.new({ X = 1, Y = Y, W = W - 9, Placeholder = "Search YouTube or paste a link" }))
SearchField:SetValue(Search.Query)
SearchField.OnSubmit = function(_, Value) RunSearch(Value) end
Page:Add(Ui.Button.new({ X = W - 8, Y = Y, W = 9, Text = "Search", Style = "primary", OnClick = function() RunSearch(SearchField.Value) end }))
local Items = {}
for Index, Entry in ipairs(Search.Results) do
local Text = Entry.Name
if (Entry.Artist and #Entry.Artist > 0) then Text = Text .. " \7 " .. Entry.Artist end
table.insert(Items, { Text = Text, Icon = Entry.Tracks and "\19" or "\14", IconFg = "SubText", Right = Entry.Tracks and (#Entry.Tracks .. " songs") or "yt", Index = Index })
end
local List = Page:Add(Ui.List.new({ X = 1, Y = Y + 1, W = W, H = H - 1, Items = Items }))
List.OnActivate = function(_, Index)
local Entry = Search.Results[Index]
if (not Entry) then return end
if (Entry.Tracks) then
PlayList(Entry.Tracks, 1)
return
end
-- Queue the other songs too so next/previous keep working.
local Queue = {}
local Start = 1
for Other, Result in ipairs(Search.Results) do
if (Result.ID) then
table.insert(Queue, ResultTracks(Result)[1])
if (Other == Index) then Start = #Queue end
end
end
PlayList(Queue, Start)
end
List.OnContext = function(_, Index, Item, X, CY)
if (not Item) then return end
local Entry = Search.Results[Index]
local Options = { "Play", "Play Next", "Add to Library" }
local Choice = Options[Ui.ContextMenu(X, CY, Options) or 0]
if (Choice == "Play") then
PlayList(ResultTracks(Entry), 1)
elseif (Choice == "Play Next") then
for Offset, Track in ipairs(ResultTracks(Entry)) do
table.insert(Player.Queue, math.min(#Player.Queue + 1, Player.Index + Offset), Track)
end
elseif (Choice == "Add to Library") then
for _, Track in ipairs(ResultTracks(Entry)) do
table.insert(Library.Tracks, Track)
end
SaveLibrary()
BlockOS.Notify("bTunes", "Added " .. Entry.Name .. " to your library")
end
end
local Message = Search.Error
if (Search.State == "searching") then
Message = "Searching..."
elseif (#Items == 0 and not Message) then
Message = "Type a song, artist or YouTube link and press Enter."
end
if (Message) then
Page:Add(Ui.Label.new({ X = 2, Y = Y + 2, W = W - 2, H = 2, Fg = "SubText", Text = Message }))
end
Page:SetFocus(SearchField)
end
local function NowPlaying(Y)
local W = Page.W
Page:Add(Ui.Canvas.new({
X = 1, Y = Y, W = W, H = 3,
OnDraw = function(Self, T, Th)
Gfx.Fill(T, Self.X, Self.Y, Self.W, 3, Th.Panel)
local Track = Player.Queue[Player.Index]
local Title = Track and Track.Name or "Not playing"
if (Player.State == "loading") then Title = "Loading " .. Title .. "..." end
if (Player.Error) then Title = Player.Error end
Gfx.Write(T, Self.X + 1, Self.Y, Gfx.Truncate(Title, Self.W - 2), Player.Error and colors.red or Th.PanelFg, Th.Panel)
local Elapsed = Player.Bytes / BytesPerSecond
local Total = Player.Total / BytesPerSecond
local Times = FormatTime(Elapsed) .. "/" .. (Total > 0 and FormatTime(Total) or "--:--")
local BarW = Self.W - #Times - 3
local Filled = (Total > 0) and math.floor(BarW * math.min(1, Elapsed / Total)) or 0
Gfx.Fill(T, Self.X + 1, Self.Y + 1, BarW, 1, Th.Border)
Gfx.Fill(T, Self.X + 1, Self.Y + 1, Filled, 1, Th.Accent)
Gfx.Write(T, Self.X + BarW + 2, Self.Y + 1, Times, Th.SubText, Th.Panel)
end
}))
local Controls = {
{ Text = "\171", Action = Previous },
{ Text = function() return (Player.State == "playing") and "\149\149" or "\16" end, Action = TogglePause },
{ Text = "\187", Action = function() if (#Player.Queue > 0) then TrackEnded() end end },
{ Text = "\143", Action = Stop }
}
local X = 2
for _, Control in ipairs(Controls) do
Page:Add(Ui.Button.new({ X = X, Y = Y + 2, W = 4, Text = Control.Text, Bg = "Panel", OnClick = Control.Action }))
X = X + 4
end
Page:Add(Ui.Button.new({ X = X + 1, Y = Y + 2, W = 3, Text = function() return Player.Shuffle and "S*" or "S" end, Bg = "Panel", OnClick = function() Player.Shuffle = not Player.Shuffle end }))
Page:Add(Ui.Button.new({ X = X + 4, Y = Y + 2, W = 3, Text = function() return Player.Repeat and "R*" or "R" end, Bg = "Panel", OnClick = function() Player.Repeat = not Player.Repeat end }))
local VolumeX = W - 12
if (VolumeX > X + 8) then
Page:Add(Ui.Button.new({ X = VolumeX, Y = Y + 2, W = 2, Text = "-", Bg = "Panel", OnClick = function() Player.Volume = math.max(0, Player.Volume - .25) end }))
Page:Add(Ui.Canvas.new({
X = VolumeX + 2, Y = Y + 2, W = 6, H = 1,
OnDraw = function(Self, T, Th)
local Filled = math.floor(Player.Volume / 3 * Self.W + .5)
Gfx.Fill(T, Self.X, Self.Y, Self.W, 1, Th.Border)
Gfx.Fill(T, Self.X, Self.Y, Filled, 1, Th.Accent)
end
}))
Page:Add(Ui.Button.new({ X = VolumeX + 8, Y = Y + 2, W = 2, Text = "+", Bg = "Panel", OnClick = function() Player.Volume = math.min(3, Player.Volume + .25) end }))
end
end
--
-- Visualizer
--
local function BuildTables()
local N = Visual.Window
local Bits = math.floor(math.log(N, 2) + .5)
for Index = 0, N - 1 do
local Reversed, Value = 0, Index
for _ = 1, Bits do
Reversed = Reversed * 2 + Value % 2
Value = math.floor(Value / 2)
end
Visual.Reverse[Index + 1] = Reversed + 1
Visual.Hann[Index + 1] = .5 - .5 * math.cos(2 * math.pi * Index / (N - 1))
end
for Index = 0, N / 2 - 1 do
Visual.Cos[Index + 1] = math.cos(2 * math.pi * Index / N)
Visual.Sin[Index + 1] = -math.sin(2 * math.pi * Index / N)
end
end
-- Splits roughly 50 Hz to 12 kHz into Count bands spaced evenly in pitch, each at least one bin wide.
local function BuildBands(Count)
if (#Visual.Reverse == 0) then BuildTables() end
Visual.Count = Count
local MaxBin = math.floor(12000 * Visual.Window / SampleRate)
Visual.Edges = { 1 }
for Band = 1, Count do
Visual.Edges[Band + 1] = math.max(Visual.Edges[Band] + 1, math.floor(MaxBin ^ (Band / Count) + .5))
end
Visual.Bands, Visual.Peaks = {}, {}
end
-- The samples the speakers are playing right now and where they start.
local function Audible()
local Now = os.clock()
for _, Chunk in ipairs(Player.Timeline) do
if (Now >= Chunk.Start and Now < Chunk.End) then
local Offset = math.floor((Now - Chunk.Start) * SampleRate) + 1
return Chunk.Samples, math.max(1, math.min(Offset, #Chunk.Samples - Visual.Window + 1))
end
end
return nil
end
-- Power of each FFT bin (0 is DC) for a Hann-windowed slice of the audio.
local function Spectrum(Samples, Offset)
local N = Visual.Window
local Re, Im = {}, {}
for Index = 1, N do
local Target = Visual.Reverse[Index]
Re[Target] = (Samples[Offset + Index - 1] or 0) * Visual.Hann[Index]
Im[Target] = 0
end
local Size = 2
while (Size <= N) do
local Half = Size / 2
local Step = N / Size
for Start = 1, N, Size do
for K = 0, Half - 1 do
local Cos, Sin = Visual.Cos[K * Step + 1], Visual.Sin[K * Step + 1]
local A, B = Start + K, Start + K + Half
local TRe = Re[B] * Cos - Im[B] * Sin
local TIm = Re[B] * Sin + Im[B] * Cos
Re[B], Im[B] = Re[A] - TRe, Im[A] - TIm
Re[A], Im[A] = Re[A] + TRe, Im[A] + TIm
end
end
Size = Size * 2
end
local Power = {}
for Bin = 0, N / 2 - 1 do
Power[Bin] = Re[Bin + 1] * Re[Bin + 1] + Im[Bin + 1] * Im[Bin + 1]
end
return Power
end
-- Loudness of one band from 0 to 1 over a 54 dB range, from its loudest bin.
local function BandLevel(Power, Band)
local Loudest = 0
for Bin = Visual.Edges[Band], Visual.Edges[Band + 1] - 1 do
if ((Power[Bin] or 0) > Loudest) then Loudest = Power[Bin] end
end
-- A full-scale sine through a Hann window peaks at a quarter of the window length.
local Amplitude = math.sqrt(Loudest) / (Visual.Window / 4)
local Decibels = 20 * math.log(Amplitude / 128 + 1e-9, 10)
return math.max(0, math.min(1, (Decibels + 54) / 54))
end
-- Moves every band towards the audio playing now: bars jump up and fall back slowly.
-- Returns false once everything has settled, so idle frames are not redrawn.
local function Analyze()
if (Visual.Count ~= Visual.Wanted) then BuildBands(Visual.Wanted) end
local Samples, Offset = Audible()
if (Player.State ~= "playing" and Player.State ~= "paused") then Samples = nil end
Visual.Wave = Samples and { Samples = Samples, Offset = Offset } or nil
local Moving = Samples ~= nil
local Power = Samples and Spectrum(Samples, Offset)
for Band = 1, Visual.Count do
local Target = Power and BandLevel(Power, Band) or 0
local Current = Visual.Bands[Band] or 0
Current = (Target > Current) and Target or math.max(Target, Current - .07)
Visual.Bands[Band] = Current
local Peak = Visual.Peaks[Band] or 0
Visual.Peaks[Band] = (Current >= Peak) and Current or math.max(Current, Peak - .025)
if (Current > 0 or Visual.Peaks[Band] > 0) then Moving = true end
end
return Moving
end
-- Teletext characters split a cell into 2x3 pixels; Bits sets them (1, 2 top row, 4, 8 middle, 16, 32 bottom).
-- The sixth bit cannot be drawn directly, so those cells swap colours instead.
local function Pixels(Bits, On, Off)
if (Bits >= 32) then
return string.char(128 + 63 - Bits), Off, On
end
return string.char(128 + Bits), On, Off
end
-- Thirds of a cell filled from the bottom (Up) or from the top.
local Thirds = {
Up = { 0, 48, 60, 63 },
Down = { 0, 3, 15, 63 }
}
-- A single third, counted from the bottom.
local Slices = { 48, 12, 3 }
local function BarColor(Fraction)
if (Fraction > .88) then return colors.red end
if (Fraction > .7) then return colors.orange end
if (Fraction > .45) then return colors.yellow end
return colors.lime
end
local function DrawBars(T, Th, X, Y, W, H)
local Count = Visual.Count
local Bg = Th.WindowBg
local BarW = math.max(1, math.floor(W / Count) - 1)
local Left = X + math.floor((W - Count * (BarW + 1) + 1) / 2)
for Band = 1, Count do
local Filled = math.floor((Visual.Bands[Band] or 0) * H * 3 + .5)
local Peak = math.floor((Visual.Peaks[Band] or 0) * H * 3 + .5)
local Column = Left + (Band - 1) * (BarW + 1)
for Row = 1, H do
local Part = math.max(0, math.min(3, Filled - (Row - 1) * 3))
local Char, Fg, CellBg = Pixels(Thirds.Up[Part + 1], BarColor(Row / H), Bg)
local PeakRow = math.ceil(Peak / 3)
if (Part == 0 and Peak > Filled and PeakRow == Row) then
Char, Fg, CellBg = Pixels(Slices[Peak - (Row - 1) * 3], Th.Text, Bg)
end
Gfx.Write(T, Column, Y + H - Row, string.rep(Char, BarW), Fg, CellBg)
end
end
end
-- Low notes in the middle spreading outwards, reflected above and below a centre line.
local function DrawMirror(T, Th, X, Y, W, H)
local Bg = Th.WindowBg
local Half = math.floor(H / 2)
local Center = X + math.floor(W / 2)
for Column = X, X + W - 1 do
local Band = math.min(Visual.Count, math.floor(math.abs(Column - Center) * Visual.Count / math.max(1, W / 2)) + 1)
local Level = Visual.Bands[Band] or 0
local Filled = math.floor(Level * Half * 3 + .5)
local Color = (Level > .75) and colors.white or ((Level > .45) and colors.lightBlue or Th.Accent)
for Row = 1, Half do
local Part = math.max(0, math.min(3, Filled - (Row - 1) * 3))
local Char, Fg, CellBg = Pixels(Thirds.Up[Part + 1], Color, Bg)
Gfx.Write(T, Column, Y + Half - Row, Char, Fg, CellBg)
Char, Fg, CellBg = Pixels(Thirds.Down[Part + 1], Color, Bg)
Gfx.Write(T, Column, Y + H - Half + Row - 1, Char, Fg, CellBg)
end
if (H % 2 == 1) then
Gfx.Write(T, Column, Y + Half, (Filled > 0) and " " or "\140", Th.Border, (Filled > 0) and Color or Bg)
end
end
end
-- An oscilloscope trace of the samples playing now, joined into a line at 2x3 pixels per cell.
local function DrawWave(T, Th, X, Y, W, H)
local PixelW, PixelH = W * 2, H * 3
local Lit = {}
local Wave = Visual.Wave
local Previous
for Pixel = 1, PixelW do
local Value = 0
if (Wave) then
Value = Wave.Samples[Wave.Offset + math.floor((Pixel - 1) * Visual.Window / PixelW)] or 0
end
local Height = math.max(1, math.min(PixelH, math.floor((.5 - Value / 256) * (PixelH - 1) + 1.5)))
local From, To = Height, Height
if (Previous) then
From, To = math.min(Previous, Height), math.max(Previous, Height)
end
for PY = From, To do
Lit[(PY - 1) * PixelW + Pixel] = true
end
Previous = Height
end
for Row = 1, H do
for Column = 1, W do
local Bits = 0
for SubY = 0, 2 do
for SubX = 0, 1 do
if (Lit[((Row - 1) * 3 + SubY) * PixelW + (Column - 1) * 2 + SubX + 1]) then
Bits = Bits + 2 ^ (SubY * 2 + SubX)
end
end
end
local Char, Fg, CellBg = Pixels(Bits, Wave and Th.Accent or Th.Border, Th.WindowBg)
Gfx.Write(T, X + Column - 1, Y + Row - 1, Char, Fg, CellBg)
end
end
end
local Drawers = { DrawBars, DrawMirror, DrawWave }
local function Visualizer(Y, H)
local Mode = Library.Visual or 1
Page:Add(Ui.Tabs.new({
X = 1, Y = Y, W = Page.W, Items = Visual.Modes, Selected = Mode,
OnChange = function(_, Index)
Library.Visual = Index
SaveLibrary()
Build()
end
}))
Visual.Wanted = math.max(4, math.min(24, math.floor(Page.W / 2)))
Page:Add(Ui.Canvas.new({
X = 1, Y = Y + 1, W = Page.W, H = H - 1,
OnDraw = function(Self, T, Th)
if (Visual.Count ~= Visual.Wanted) then BuildBands(Visual.Wanted) end
Drawers[Mode](T, Th, Self.X, Self.Y, Self.W, Self.H)
if (Player.State == "stopped" or #Speakers() == 0) then
Gfx.Center(T, Self.Y + math.floor(Self.H / 2) - 1, (#Speakers() == 0) and "Attach a speaker to play music" or "\14 bTunes", Th.SubText, Th.WindowBg, Self.X, Self.W)
end
end,
OnEvent = function(_, Name)
if (Name ~= "mouse_click") then return false end
Library.Visual = Mode % #Visual.Modes + 1
SaveLibrary()
Build()
return true
end
}))
end
local function BuildAdd(Y)
local FieldW = Page.W - 9
Page:Add(Ui.Label.new({ X = 2, Y = Y, Text = "Name" }))
local Name = Page:Add(Ui.Input.new({ X = 8, Y = Y, W = FieldW, Placeholder = "Track name" }))
Page:Add(Ui.Label.new({ X = 2, Y = Y + 2, Text = "URL" }))
local Url = Page:Add(Ui.Input.new({ X = 8, Y = Y + 2, W = FieldW, Placeholder = "YouTube or .dfpwm link" }))
Page:Add(Ui.Label.new({ X = 2, Y = Y + 4, W = Page.W - 2, H = 2, Fg = "SubText", Text = "Paste a YouTube video or a link to DFPWM audio. Playlists open in Search." }))
local function Track()
local Link = Url.Value:gsub("^%s+", ""):gsub("%s+$", "")
if (not Link:match("^https?://")) then
Ui.Alert("bTunes", "Enter an http(s) link.")
return nil
end
if (IsYouTubePlaylist(Link)) then
Tab = 2
RunSearch(Link)
return nil
end
local ID = YouTubeID(Link)
if (ID) then
if (#Name.Value > 0) then return { Name = Name.Value, ID = ID } end
local Entry = { Name = "YouTube " .. ID, ID = ID }
LookupTitle(Entry)
return Entry
end
local Title = (#Name.Value > 0) and Name.Value or (Link:match("([^/]+)%.%w+$") or Link)
return { Name = Title, Url = Link }
end
Page:Add(Ui.Button.new({ X = 2, Y = Y + 7, Text = " Play Now ", OnClick = function()
local Entry = Track()
if (Entry) then PlayList({ Entry }, 1) end
end }))
Page:Add(Ui.Button.new({ X = 14, Y = Y + 7, Text = " Add to Library ", Style = "primary", OnClick = function()
local Entry = Track()
if (not Entry) then return end
table.insert(Library.Tracks, Entry)
SaveLibrary()
Name:SetValue("")
Url:SetValue("")
Tab = 1
Build()
end }))
Page:Add(Ui.Button.new({ X = math.max(32, Page.W - 8), Y = Y + 7, Text = " Server ", OnClick = function()
local Value = Ui.Prompt("YouTube Server", "Streaming server URL:", Server())
if (not Value) then return end
Value = Value:gsub("^%s+", ""):gsub("%s+$", "")
Library.Server = (#Value > 0 and Value ~= DefaultServer) and Value or nil
SaveLibrary()
end }))
Page:SetFocus(Url)
end
function Build()
Page:Clear()
local W, H = Page.W, Page.H
Page:Add(Ui.Tabs.new({
X = 1, Y = 1, W = W, Items = { "Library", "Search", "Files", "Add", "Visual" }, Selected = Tab,
OnChange = function(_, Index)
Tab = Index
Build()
end
}))
local BodyH = H - 4
if (Tab == 1) then
TrackList(2, BodyH, Library.Tracks, true)
elseif (Tab == 2) then
BuildSearch(2, BodyH)
elseif (Tab == 3) then
local Files = LocalFiles()
TrackList(2, BodyH - 1, Files, false)
Page:Add(Ui.Button.new({ X = W - 9, Y = H - 3, W = 9, Text = "Open...", OnClick = function()
local Path = Ui.PickFile({ Dir = "/home/Music", Extension = ".dfpwm" })
if (Path) then PlayList({ { Name = fs.getName(Path), Path = Path } }, 1) end
end }))
elseif (Tab == 4) then
BuildAdd(3)
else
Visualizer(2, BodyH)
end
NowPlaying(H - 2)
Page:Invalidate()
end
function Page:OnLayout()
Build()
end
function Page:OnEvent(Name, A, B)
if (Name == "speaker_audio_empty") then
if (Player.State == "playing") then Feed() end
return false
elseif (Name == "http_success" and A == Player.RequestUrl) then
Player.RequestUrl = nil
local Headers = B.getResponseHeaders and B.getResponseHeaders() or {}
StartStream(B, tonumber(Headers["Content-Length"] or Headers["content-length"] or 0))
return true
elseif (Name == "http_failure" and A == Player.RequestUrl) then
Player.RequestUrl = nil
Player.State = "stopped"
Player.Error = "Could not load track"
return true
elseif ((Name == "http_success" or Name == "http_failure") and Lookups[A]) then
local Track = Lookups[A]
Lookups[A] = nil
if (Name == "http_success") then
LookupDone(Track, B)
Build()
end
return true
elseif (Name == "http_success" and A == Search.RequestUrl) then
Search.RequestUrl = nil
SearchDone(B)
if (Tab == 2) then Build() end
return true
elseif (Name == "http_failure" and A == Search.RequestUrl) then
Search.RequestUrl = nil
Search.State = "idle"
Search.Error = "Search failed: " .. tostring(B or "no connection")
if (Tab == 2) then Build() end
return true
elseif (Name == "peripheral" or Name == "peripheral_detach" or Name == "disk" or Name == "disk_eject") then
if (Tab == 3) then Build() end
return true
elseif (Name == "blockos_open" and A) then
PlayList({ { Name = fs.getName(A), Path = A } }, 1)
return true
elseif (Name == "key" and A == keys.space and not (Page.Focus and Page.Focus.Value)) then
TogglePause()
return true
end
return false
end
function Page:OnTerminate()
Stop()
self:Stop()
end
Page:SetInterval(.25, function()
return Player.State ~= "stopped"
end)
Page:SetInterval(.1, function()
return Tab == 5 and Analyze()
end)
if (Args[1] and fs.exists(Args[1])) then
PlayList({ { Name = fs.getName(Args[1]), Path = Args[1] } }, 1)
end
Page:Run()
Stop()
]]
Files["blockos/apps/calculator/app.cfg"] = [[
{ Name = "Calculator", Description = "A four-function calculator with keyboard support.", Width = 22, Height = 14, Icon = { Lines = { "+-=", "x/%" }, Fg = "white", Bg = "orange" } }
]]
Files["blockos/apps/calculator/main.lua"] = [[
-- Calculator: a basic four-function calculator for BlockOS.
local Ui = BlockOS.Require("ui")
local Gfx = BlockOS.Require("gfx")
local Page = Ui.Page.new()
local Display = "0"
local Stored = nil
local Operator = nil
local Fresh = true
local Layout = {
{ "C", "+/-", "%", "/" },
{ "7", "8", "9", "*" },
{ "4", "5", "6", "-" },
{ "1", "2", "3", "+" },
{ "0", ".", "<", "=" }
}
local function Format(Value)
if (Value ~= Value or Value == math.huge or Value == -math.huge) then return "Error" end
if (Value == math.floor(Value) and math.abs(Value) < 1e15) then
return string.format("%d", Value)
end
return string.format("%.10g", Value)
end
local function Apply(A, B, Op)
if (Op == "+") then return A + B end
if (Op == "-") then return A - B end
if (Op == "*") then return A * B end
if (Op == "/") then return A / B end
return B
end
local function Press(Key)
local Value = tonumber(Display) or 0
if (Key:match("^%d$")) then
if (Fresh or Display == "0") then
Display = Key
elseif (#Display < 15) then
Display = Display .. Key
end
Fresh = false
elseif (Key == ".") then
if (Fresh) then
Display = "0."
elseif (not Display:find(".", 1, true)) then
Display = Display .. "."
end
Fresh = false
elseif (Key == "C") then
Display, Stored, Operator, Fresh = "0", nil, nil, true
elseif (Key == "<") then
Display = Fresh and "0" or Display:sub(1, -2)
if (Display == "" or Display == "-") then Display = "0" end
elseif (Key == "+/-") then
Display = Format(-Value)
elseif (Key == "%") then
Display = Format(Value / 100)
Fresh = true
elseif (Key == "=") then
if (Operator and Stored) then
Display = Format(Apply(Stored, Value, Operator))
end
Stored, Operator, Fresh = nil, nil, true
else
if (Operator and Stored and not Fresh) then
Value = Apply(Stored, Value, Operator)
Display = Format(Value)
end
Stored, Operator, Fresh = Value, Key, true
end
Page:Invalidate()
end
local function Build()
Page:Clear()
local W, H = Page.W, Page.H
Page:Add(Ui.Canvas.new({
X = 1, Y = 1, W = W, H = 3,
OnDraw = function(Self, T, Th)
Gfx.Fill(T, Self.X, Self.Y, Self.W, Self.H, Th.Panel)
local Pending = (Stored and Operator) and (Format(Stored) .. " " .. Operator) or ""
Gfx.Write(T, Self.X, Self.Y, Gfx.Pad(Pending, Self.W - 1, "right"), Th.SubText, Th.Panel)
Gfx.Write(T, Self.X, Self.Y + 1, Gfx.Pad(Display, Self.W - 1, "right"), Th.PanelFg, Th.Panel)
end
}))
local CellW = math.max(3, math.floor(W / 4))
local CellH = math.max(1, math.floor((H - 3) / #Layout))
for Row, Keys in ipairs(Layout) do
for Column, Key in ipairs(Keys) do
local Style = "default"
if (Column == 4) then Style = "primary" end
Page:Add(Ui.Button.new({
X = (Column - 1) * CellW + 1,
Y = 4 + (Row - 1) * CellH,
W = (Column == 4) and (W - CellW * 3) or (CellW - 1),
H = math.max(1, CellH - (CellH > 1 and 1 or 0)),
Text = Key,
Style = Style,
OnClick = function() Press(Key) end
}))
end
end
Page:Invalidate()
end
function Page:OnLayout()
Build()
end
function Page:OnEvent(Name, A)
if (Name == "char") then
if (A:match("^[%d%.%+%-%*/%%=]$")) then
Press(A)
return true
elseif (A == "c") then
Press("C")
return true
end
elseif (Name == "key") then
if (A == keys.enter or A == keys.numPadEnter) then
Press("=")
return true
elseif (A == keys.backspace) then
Press("<")
return true
elseif (A == keys.delete) then
Press("C")
return true
end
end
return false
end
Page:Run()
]]
Files["blockos/apps/clock/app.cfg"] = [[
{ Name = "Clock", Description = "World time, alarms, stopwatch and timer.", Width = 38, Height = 13, Single = true, Icon = { Lines = { "12:", "00 " }, Fg = "white", Bg = "black" } }
]]
Files["blockos/apps/clock/main.lua"] = [[
-- Clock: big clock, alarms (real or Minecraft time), stopwatch and timer.
local Ui = BlockOS.Require("ui")
local Gfx = BlockOS.Require("gfx")
local Config = BlockOS.Require("config")
local Schedule = BlockOS.Require("schedule")
local Page = Ui.Page.new()
local Tab = 1
local Build
local Font = Gfx.Font
local Stopwatch = { Running = false, Start = 0, Elapsed = 0, Laps = {} }
local Timer = { Running = false, Duration = 300000, Remaining = 300000, EndsAt = 0, Fired = false }
local function Now()
return os.epoch("utc")
end
local function BigWidth(Text)
local Width = 0
for Index = 1, #Text do
local Glyph = Font[Text:sub(Index, Index)]
Width = Width + (Glyph and #Glyph[1] or 1) + 1
end
return Width - 1
end
local function DrawBig(T, X, Y, Text, Color, Bg)
for Index = 1, #Text do
local Glyph = Font[Text:sub(Index, Index)]
if (Glyph) then
for Row = 1, 5 do
local Line = Glyph[Row]
for Column = 1, #Line do
local Filled = Line:sub(Column, Column) == "#"
Gfx.Write(T, X + Column - 1, Y + Row - 1, " ", Color, Filled and Color or Bg)
end
end
X = X + #Glyph[1] + 1
else
X = X + 2
end
end
end
local function CenterBig(T, Y, Text, Color, Bg, W)
DrawBig(T, math.floor((W - BigWidth(Text)) / 2) + 1, Y, Text, Color, Bg)
end
local function FormatDuration(Milliseconds, Centis)
local Total = math.max(0, math.floor(Milliseconds / 1000))
local Hours = math.floor(Total / 3600)
local Minutes = math.floor(Total / 60) % 60
local Seconds = Total % 60
local Text
if (Hours > 0) then
Text = string.format("%d:%02d:%02d", Hours, Minutes, Seconds)
else
Text = string.format("%02d:%02d", Minutes, Seconds)
end
if (Centis) then
Text = Text .. string.format(".%02d", math.floor(Milliseconds / 10) % 100)
end
return Text
end
local function StopwatchTime()
if (Stopwatch.Running) then
return Stopwatch.Elapsed + (Now() - Stopwatch.Start)
end
return Stopwatch.Elapsed
end
local function TimerRemaining()
if (Timer.Running) then
return math.max(0, Timer.EndsAt - Now())
end
return Timer.Remaining
end
local function BuildClock(W, H)
Page:Add(Ui.Canvas.new({
X = 1, Y = 2, W = W, H = H - 1,
OnDraw = function(Self, T, Th)
local Settings = Config.Settings()
local Text = os.date(Settings.Clock24 and "%H:%M" or "%I:%M")
CenterBig(T, Self.Y + 1, Text, Th.Accent, Th.WindowBg, W)
Gfx.Center(T, Self.Y + 7, os.date("%A, %d %B %Y") .. " " .. os.date(":%S") .. (Settings.Clock24 and "" or os.date(" %p")), Th.Text, Th.WindowBg)
local Game = textutils.formatTime(os.time(), Settings.Clock24)
Gfx.Center(T, Self.Y + 9, "Minecraft: Day " .. os.day() .. ", " .. Game, Th.SubText, Th.WindowBg)
local Hours = os.time()
local Phase = (Hours >= 6 and Hours < 18) and "\15 Daytime" or "\7 Night"
Gfx.Center(T, Self.Y + 10, Phase, (Hours >= 6 and Hours < 18) and colors.orange or colors.lightBlue, Th.WindowBg)
end
}))
end
local function FormatAlarm(Alarm)
if (Alarm.Source == "game") then
return textutils.formatTime(Alarm.Time, true) .. " MC"
end
return string.format("%02d:%02d", math.floor(Alarm.Time / 60), Alarm.Time % 60)
end
local function BuildAlarms(W, H)
local Data = Schedule.Load()
local List = Page:Add(Ui.List.new({ X = 1, Y = 2, W = W, H = H - 3 }))
local Items = {}
for _, Alarm in ipairs(Data.Alarms) do
table.insert(Items, {
Text = FormatAlarm(Alarm) .. " " .. (Alarm.Label or "Alarm"),
Icon = Alarm.Enabled and "\7" or "o",
IconFg = Alarm.Enabled and "Accent" or "SubText",
Right = Alarm.Enabled and "on" or "off",
Alarm = Alarm
})
end
List:SetItems(Items)
List.OnActivate = function(_, _, Item)
Item.Alarm.Enabled = not Item.Alarm.Enabled
Schedule.Save()
Build()
end
List.OnContext = function(_, _, Item, X, Y)
if (not Item) then return end
local Choice = Ui.ContextMenu(X, Y, { Item.Alarm.Enabled and "Turn Off" or "Turn On", "Delete" })
if (Choice == 1) then
Item.Alarm.Enabled = not Item.Alarm.Enabled
Schedule.Save()
elseif (Choice == 2) then
Schedule.RemoveAlarm(Item.Alarm.ID)
end
Build()
end
if (#Items == 0) then
Page:Add(Ui.Label.new({ X = 2, Y = 3, W = W - 2, Text = "No alarms. Alarms ring while BlockOS is running.", Fg = "SubText", H = 2 }))
end
Page:Add(Ui.Button.new({ X = 2, Y = H, Text = " + Add Alarm ", Style = "primary", OnClick = function()
local Index = Ui.Choose("Alarm clock", { "Real time", "Minecraft time" })
if (not Index) then return end
local Text = Ui.Prompt("New Alarm", "Time (HH:MM, 24-hour):", Index == 1 and os.date("%H:%M") or "06:00")
if (not Text) then return end
local Hour, Minute = Text:match("^(%d%d?):(%d%d)$")
if (not Hour or tonumber(Hour) > 23 or tonumber(Minute) > 59) then
Ui.Alert("New Alarm", "Enter the time as HH:MM.")
return
end
local Label = Ui.Prompt("New Alarm", "Label:", "Alarm") or "Alarm"
if (Index == 1) then
Schedule.AddAlarm({ Label = Label, Time = tonumber(Hour) * 60 + tonumber(Minute), Source = "real" })
else
Schedule.AddAlarm({ Label = Label, Time = tonumber(Hour) + tonumber(Minute) / 60, Source = "game" })
end
Build()
end }))
Page:Add(Ui.Label.new({ X = 18, Y = H, W = W - 18, Text = "Double-click toggles", Fg = "SubText" }))
end
local function BuildStopwatch(W, H)
Page:Add(Ui.Canvas.new({
X = 1, Y = 2, W = W, H = 6,
OnDraw = function(Self, T, Th)
local Text = FormatDuration(StopwatchTime(), false)
CenterBig(T, Self.Y + 1, Text, Stopwatch.Running and Th.Accent or Th.Text, Th.WindowBg, W)
Gfx.Center(T, Self.Y + 6, string.format(".%02d", math.floor(StopwatchTime() / 10) % 100), Th.SubText, Th.WindowBg)
end
}))
local Laps = {}
for Index = #Stopwatch.Laps, 1, -1 do
table.insert(Laps, { Text = "Lap " .. Index, Right = FormatDuration(Stopwatch.Laps[Index], true) })
end
Page:Add(Ui.List.new({ X = 2, Y = 9, W = W - 2, H = math.max(1, H - 10), Items = Laps }))
Page:Add(Ui.Button.new({ X = 2, Y = H, W = 9, Text = function() return Stopwatch.Running and "Stop" or "Start" end, Style = "primary", OnClick = function()
if (Stopwatch.Running) then
Stopwatch.Elapsed = StopwatchTime()
Stopwatch.Running = false
else
Stopwatch.Start = Now()
Stopwatch.Running = true
end
end }))
Page:Add(Ui.Button.new({ X = 12, Y = H, W = 7, Text = "Lap", OnClick = function()
if (Stopwatch.Running) then
table.insert(Stopwatch.Laps, StopwatchTime())
Build()
end
end }))
Page:Add(Ui.Button.new({ X = 20, Y = H, W = 8, Text = "Reset", OnClick = function()
Stopwatch = { Running = false, Start = 0, Elapsed = 0, Laps = {} }
Build()
end }))
end
local function BuildTimer(W, H)
Page:Add(Ui.Canvas.new({
X = 1, Y = 2, W = W, H = 6,
OnDraw = function(Self, T, Th)
local Remaining = TimerRemaining()
CenterBig(T, Self.Y + 1, FormatDuration(Remaining + 999, false), Timer.Running and Th.Accent or Th.Text, Th.WindowBg, W)
end
}))
Page:Add(Ui.Progress.new({ X = 3, Y = 8, W = W - 4, Value = function()
return Timer.Duration > 0 and (1 - TimerRemaining() / Timer.Duration) or 0
end }))
local function Adjust(Delta)
if (Timer.Running) then return end
Timer.Remaining = math.max(0, Timer.Remaining + Delta)
Timer.Duration = Timer.Remaining
end
local Presets = { { "-1m", -60000 }, { "+1m", 60000 }, { "+5m", 300000 }, { "+10s", 10000 } }
local X = 2
for _, Preset in ipairs(Presets) do
Page:Add(Ui.Button.new({ X = X, Y = 10, W = 6, Text = Preset[1], OnClick = function() Adjust(Preset[2]) end }))
X = X + 7
end
Page:Add(Ui.Button.new({ X = 2, Y = H, W = 9, Text = function() return Timer.Running and "Pause" or "Start" end, Style = "primary", OnClick = function()
if (Timer.Running) then
Timer.Remaining = TimerRemaining()
Timer.Running = false
elseif (Timer.Remaining > 0) then
Timer.EndsAt = Now() + Timer.Remaining
Timer.Running = true
Timer.Fired = false
end
end }))
Page:Add(Ui.Button.new({ X = 12, Y = H, W = 8, Text = "Reset", OnClick = function()
Timer.Running = false
Timer.Remaining = Timer.Duration
end }))
end
function Build()
Page:Clear()
local W, H = Page.W, Page.H
Page:Add(Ui.Tabs.new({
X = 1, Y = 1, W = W, Items = { "Clock", "Alarms", "Stopwatch", "Timer" }, Selected = Tab,
OnChange = function(_, Index)
Tab = Index
Build()
end
}))
local Builders = { BuildClock, BuildAlarms, BuildStopwatch, BuildTimer }
Builders[Tab](W, H)
Page:Invalidate()
end
function Page:OnLayout()
Build()
end
function Page:OnEvent(Name)
if (Name == "blockos_schedule" and Tab == 2) then
Build()
return true
end
return false
end
Page:SetInterval(.1, function()
if (Timer.Running and TimerRemaining() <= 0 and not Timer.Fired) then
Timer.Fired = true
Timer.Running = false
Timer.Remaining = 0
BlockOS.Notify("Timer", "Time's up!", { AppID = "clock", Repeat = 3, Duration = 10 })
end
if (Tab == 1) then
return os.clock() % 1 < .1
end
return (Tab == 3 and Stopwatch.Running) or (Tab == 4)
end)
Page:Run()
]]
Files["blockos/apps/devices/app.cfg"] = [[
{ Name = "Devices", Description = "Inspect and control attached peripherals and redstone.", Width = 46, Height = 15, Single = true, Icon = { Lines = { "[\7]", "|||" }, Fg = "cyan", Bg = "black" } }
]]
Files["blockos/apps/devices/main.lua"] = [[
-- Devices: inspect this computer, redstone sides and every attached peripheral, with per-type actions.
local Ui = BlockOS.Require("ui")
local Gfx = BlockOS.Require("gfx")
local Page = Ui.Page.new()
local SelectedID = "computer"
local Build
local TypeIcons = {
monitor = { "\8", colors.lightBlue }, modem = { "\29", colors.orange }, speaker = { "\14", colors.red },
drive = { "\22", colors.gray }, printer = { "\127", colors.yellow }, computer = { "\8", colors.cyan },
turtle = { "\4", colors.lime }, redstone = { "\7", colors.red }
}
local function Entries()
local List = {
{ ID = "computer", Text = "This Computer", Icon = "\8", IconFg = colors.cyan },
{ ID = "redstone", Text = "Redstone", Icon = "\7", IconFg = colors.red }
}
local Names = peripheral.getNames()
table.sort(Names)
for _, Name in ipairs(Names) do
local Kind = peripheral.getType(Name)
local Icon = TypeIcons[Kind] or { "\4", colors.gray }
table.insert(List, { ID = Name, Text = Name, Icon = Icon[1], IconFg = Icon[2], Kind = Kind })
end
return List
end
local function Info(X, Y, W, Lines)
for Index, Line in ipairs(Lines) do
Page:Add(Ui.Label.new({ X = X, Y = Y + Index - 1, W = 10, Text = Line[1], Fg = "SubText" }))
Page:Add(Ui.Label.new({ X = X + 10, Y = Y + Index - 1, W = W - 10, Text = tostring(Line[2]) }))
end
return Y + #Lines + 1
end
local function Buttons(X, Y, W, List)
local BX = X
for _, Entry in ipairs(List) do
local Width = #Entry[1] + 2
if (BX + Width > X + W) then
BX = X
Y = Y + 2
end
Page:Add(Ui.Button.new({ X = BX, Y = Y, W = Width, Text = Entry[1], Style = Entry[3], OnClick = function()
local Ok, Err = pcall(Entry[2])
if (not Ok) then Ui.Alert("Devices", tostring(Err)) end
Build()
end }))
BX = BX + Width + 1
end
return Y + 2
end
local Details = {}
function Details.computer(X, Y, W)
local Uptime = math.floor(os.clock())
Info(X, Y, W, {
{ "Label", os.getComputerLabel() or "(none)" },
{ "ID", os.getComputerID() },
{ "Kind", (turtle and "Turtle" or (pocket and "Pocket computer" or "Computer")) .. (term.isColor() and ", advanced" or "") },
{ "Version", _HOST or os.version() },
{ "Uptime", string.format("%dh %02dm %02ds", math.floor(Uptime / 3600), math.floor(Uptime / 60) % 60, Uptime % 60) },
{ "World", "Day " .. os.day() .. ", " .. textutils.formatTime(os.time(), true) },
{ "Storage", Gfx.FormatSize(fs.getFreeSpace("/")) .. " free" },
{ "Devices", #peripheral.getNames() .. " attached" }
})
end
function Details.redstone(X, Y, W)
Page:Add(Ui.Label.new({ X = X, Y = Y, W = W, Text = "Side In Out", Fg = "SubText" }))
for Index, Side in ipairs(redstone.getSides()) do
local Row = Y + Index
Page:Add(Ui.Label.new({ X = X, Y = Row, W = 9, Text = Side }))
local Input = redstone.getAnalogInput(Side)
Page:Add(Ui.Label.new({ X = X + 10, Y = Row, W = 3, Text = tostring(Input), Fg = (Input > 0) and colors.red or "SubText" }))
Page:Add(Ui.Toggle.new({ X = X + 14, Y = Row, Value = redstone.getOutput(Side), OnChange = function(_, Value)
redstone.setOutput(Side, Value)
end }))
end
end
local function MonitorDetails(Name, X, Y, W)
local Monitor = peripheral.wrap(Name)
local MW, MH = Monitor.getSize()
Y = Info(X, Y, W, { { "Size", MW .. " x " .. MH }, { "Colour", Monitor.isColor() and "yes" or "no" }, { "Scale", Monitor.getTextScale and Monitor.getTextScale() or "?" } })
Buttons(X, Y, W, {
{ "Test Pattern", function()
local Palette = { colors.red, colors.orange, colors.yellow, colors.lime, colors.cyan, colors.blue, colors.purple, colors.magenta }
for Row = 1, MH do
Monitor.setCursorPos(1, Row)
Monitor.setBackgroundColor(Palette[(Row - 1) % #Palette + 1])
Monitor.clearLine()
end
Monitor.setCursorPos(2, 2)
Monitor.setTextColor(colors.white)
Monitor.write("BlockOS")
end },
{ "Clear", function()
Monitor.setBackgroundColor(colors.black)
Monitor.clear()
end },
{ "Scale -", function() Monitor.setTextScale(math.max(.5, Monitor.getTextScale() - .5)) end },
{ "Scale +", function() Monitor.setTextScale(math.min(5, Monitor.getTextScale() + .5)) end }
})
end
local function ModemDetails(Name, X, Y, W)
local Modem = peripheral.wrap(Name)
local Lines = { { "Type", Modem.isWireless() and "Wireless" or "Wired" }, { "Rednet", rednet.isOpen(Name) and "open" or "closed" } }
if (not Modem.isWireless() and Modem.getNamesRemote) then
table.insert(Lines, { "Network", #Modem.getNamesRemote() .. " remote peripherals" })
end
Y = Info(X, Y, W, Lines)
Buttons(X, Y, W, {
{ rednet.isOpen(Name) and "Close Rednet" or "Open Rednet", function()
if (rednet.isOpen(Name)) then rednet.close(Name) else rednet.open(Name) end
end }
})
end
local function SpeakerDetails(Name, X, Y, W)
local Speaker = peripheral.wrap(Name)
Y = Info(X, Y, W, { { "Type", "Speaker" } })
Buttons(X, Y, W, {
{ "Chime", function()
for Index, Pitch in ipairs({ 12, 16, 19, 24 }) do
Speaker.playNote("chime", 1, Pitch)
if (Index < 4) then sleep(.15) end
end
end },
{ "Bell", function() Speaker.playNote("bell", 1, 12) end },
{ "Sound...", function()
local Sound = Ui.Prompt("Play Sound", "Sound ID:", "entity.experience_orb.pickup")
if (Sound) then Speaker.playSound(Sound) end
end },
{ "Stop", function() Speaker.stop() end }
})
end
local function DriveDetails(Name, X, Y, W)
local Lines = { { "Disk", disk.isPresent(Name) and "inserted" or "empty" } }
if (disk.hasData(Name)) then
table.insert(Lines, { "Label", disk.getLabel(Name) or "(none)" })
table.insert(Lines, { "Mount", "/" .. disk.getMountPath(Name) })
table.insert(Lines, { "Disk ID", tostring(disk.getID(Name)) })
elseif (disk.hasAudio(Name)) then
table.insert(Lines, { "Music", disk.getAudioTitle(Name) })
end
Y = Info(X, Y, W, Lines)
local Actions = {}
if (disk.hasData(Name)) then
table.insert(Actions, { "Open in Finder", function() BlockOS.Launch("finder", { "/" .. disk.getMountPath(Name) }) end })
end
if (disk.hasAudio(Name)) then
table.insert(Actions, { "Play", function() disk.playAudio(Name) end })
table.insert(Actions, { "Stop", function() disk.stopAudio(Name) end })
end
if (disk.isPresent(Name)) then
table.insert(Actions, { "Eject", function() disk.eject(Name) end })
end
Buttons(X, Y, W, Actions)
end
local function PrinterDetails(Name, X, Y, W)
local Printer = peripheral.wrap(Name)
Y = Info(X, Y, W, { { "Ink", Printer.getInkLevel() }, { "Paper", Printer.getPaperLevel() } })
local function PrintLines(Title, Lines)
if (not Printer.newPage()) then error("Add ink and paper first.", 0) end
Printer.setPageTitle(Title)
local PW, PH = Printer.getPageSize()
local Row = 1
for _, Line in ipairs(Lines) do
for _, Piece in ipairs(Gfx.Wrap(Line, PW)) do
if (Row > PH) then
Printer.endPage()
if (not Printer.newPage()) then return end
Printer.setPageTitle(Title)
Row = 1
end
Printer.setCursorPos(1, Row)
Printer.write(Piece)
Row = Row + 1
end
end
Printer.endPage()
end
Buttons(X, Y, W, {
{ "Test Page", function()
PrintLines("BlockOS Test", { "BlockOS printer test", "", "Computer #" .. os.getComputerID(), os.date("%Y-%m-%d %H:%M"), "", "If you can read this, printing works." })
end },
{ "Print File...", function()
local Path = Ui.PickFile({ Dir = "/home/Documents" })
if (not Path) then return end
local Lines = {}
local Handle = fs.open(Path, "r")
for Line in (Handle.readAll() .. "\n"):gmatch("(.-)\n") do
table.insert(Lines, Line)
end
Handle.close()
PrintLines(fs.getName(Path), Lines)
end }
})
end
local function ComputerDetails(Name, X, Y, W)
local Remote = peripheral.wrap(Name)
Y = Info(X, Y, W, { { "ID", Remote.getID() }, { "Label", Remote.getLabel() or "(none)" }, { "Running", Remote.isOn() and "yes" or "no" } })
Buttons(X, Y, W, {
{ "Turn On", function() Remote.turnOn() end },
{ "Reboot", function() Remote.reboot() end },
{ "Shut Down", function() Remote.shutdown() end, "danger" }
})
end
local function GenericDetails(Name, X, Y, W, H)
Page:Add(Ui.Label.new({ X = X, Y = Y, W = W, Text = "Methods", Fg = "SubText" }))
local Items = {}
local Methods = peripheral.getMethods(Name) or {}
table.sort(Methods)
for _, Method in ipairs(Methods) do
table.insert(Items, { Text = Method .. "()" })
end
Page:Add(Ui.List.new({ X = X, Y = Y + 1, W = W, H = math.max(1, H - 2), Items = Items }))
end
function Build()
Page:Clear()
local W, H = Page.W, Page.H
local SideW = (W >= 40) and 16 or 0
local List = Page:Add(Ui.List.new({ X = 1, Y = 1, W = SideW, H = H, Bg = "Panel", SingleClick = true, Visible = SideW > 0 }))
local Items = Entries()
List:SetItems(Items)
local Current
for Index, Item in ipairs(Items) do
if (Item.ID == SelectedID) then
List.Selected = Index
Current = Item
end
end
if (not Current) then
SelectedID = "computer"
Current = Items[1]
List.Selected = 1
end
List.OnActivate = function(_, _, Item)
SelectedID = Item.ID
Build()
end
local X = SideW + 2
local ContentW = W - SideW - 2
local Title = Current.Kind and (Current.Text .. " (" .. Current.Kind .. ")") or Current.Text
if (SideW == 0) then
Page:Add(Ui.Button.new({ X = 1, Y = 1, W = W, Text = "\31 " .. Title, Bg = "Panel", OnClick = function()
local Names = {}
for _, Item in ipairs(Items) do table.insert(Names, Item.Text) end
local Index = Ui.Choose("Devices", Names)
if (Index) then
SelectedID = Items[Index].ID
Build()
end
end }))
else
Page:Add(Ui.Label.new({ X = X, Y = 1, W = ContentW, Text = Title, Fg = "Accent" }))
end
local Y = 3
if (Current.ID == "computer" or Current.ID == "redstone") then
Details[Current.ID](X, Y, ContentW, H - 2)
elseif (Current.Kind == "monitor") then
MonitorDetails(Current.ID, X, Y, ContentW)
elseif (Current.Kind == "modem") then
ModemDetails(Current.ID, X, Y, ContentW)
elseif (Current.Kind == "speaker") then
SpeakerDetails(Current.ID, X, Y, ContentW)
elseif (Current.Kind == "drive") then
DriveDetails(Current.ID, X, Y, ContentW)
elseif (Current.Kind == "printer") then
PrinterDetails(Current.ID, X, Y, ContentW)
elseif (Current.Kind == "computer" or Current.Kind == "turtle") then
ComputerDetails(Current.ID, X, Y, ContentW)
else
GenericDetails(Current.ID, X, Y, ContentW, H - 2)
end
Page:Invalidate()
end
function Page:OnLayout()
Build()
end
function Page:OnEvent(Name)
if (Name == "peripheral" or Name == "peripheral_detach" or Name == "disk" or Name == "disk_eject" or Name == "monitor_resize") then
Build()
return true
elseif (Name == "redstone" and SelectedID == "redstone") then
Build()
return true
end
return false
end
Page:SetInterval(1, function()
if (SelectedID ~= "computer") then return false end
Build()
return true
end)
Page:Run()
]]
Files["blockos/apps/diskcopy/app.cfg"] = [[
{ Name = "DiskCopy", Description = "Copy, back up, erase and clone floppy disks.", Width = 42, Height = 14, Single = true, Icon = { Lines = { "[=]", "[=]" }, Fg = "black", Bg = "lightGray" } }
]]
Files["blockos/apps/diskcopy/main.lua"] = [[
-- DiskCopy: duplicate, back up, restore, erase and label floppy disks, or make an installer disk.
local Ui = BlockOS.Require("ui")
local Gfx = BlockOS.Require("gfx")
local Config = BlockOS.Require("config")
local Page = Ui.Page.new()
local Drives = {}
local Progress = 0
local Status = "Insert a disk into a disk drive."
local Busy = false
local DriveList
local Build
local function ScanDrives()
Drives = {}
for _, Name in ipairs(peripheral.getNames()) do
if (peripheral.getType(Name) == "drive") then
local Entry = { Name = Name, Present = disk.isPresent(Name), HasData = disk.hasData(Name) }
if (Entry.HasData) then
Entry.Mount = "/" .. disk.getMountPath(Name)
Entry.Label = disk.getLabel(Name)
Entry.Free = fs.getFreeSpace(Entry.Mount)
Entry.Capacity = fs.getCapacity and fs.getCapacity(Entry.Mount) or nil
elseif (Entry.Present and disk.hasAudio(Name)) then
Entry.Audio = disk.getAudioTitle(Name)
end
table.insert(Drives, Entry)
end
end
end
local function ListFiles(Root, Relative, Into, Exclude)
Into = Into or {}
local Dir = fs.combine(Root, Relative or "")
for _, Name in ipairs(fs.list(Dir)) do
local Child = fs.combine(Relative or "", Name)
if (not (Exclude and Exclude[Child])) then
if (fs.isDir(fs.combine(Root, Child))) then
table.insert(Into, { Path = Child, Dir = true })
ListFiles(Root, Child, Into, Exclude)
else
table.insert(Into, { Path = Child, Size = fs.getSize(fs.combine(Root, Child)) })
end
end
end
return Into
end
local function TreeSize(Files)
local Total = 0
for _, File in ipairs(Files) do
Total = Total + (File.Size or 0)
end
return Total
end
local function SetProgress(Value, Text)
Progress = Value
Status = Text
Page:Draw()
end
local function CopyTree(Source, Target, Exclude)
local Files = ListFiles(Source, nil, nil, Exclude)
local Size = TreeSize(Files)
if (Size > fs.getFreeSpace(Target)) then
return false, "Not enough space: needs " .. Gfx.FormatSize(Size) .. ", " .. Gfx.FormatSize(fs.getFreeSpace(Target)) .. " free."
end
local Done = 0
for Index, File in ipairs(Files) do
local Destination = fs.combine(Target, File.Path)
if (File.Dir) then
if (not fs.exists(Destination)) then fs.makeDir(Destination) end
else
if (fs.exists(Destination)) then fs.delete(Destination) end
fs.copy(fs.combine(Source, File.Path), Destination)
Done = Done + File.Size
end
SetProgress(Index / #Files, "Copying " .. File.Path)
if (Index % 8 == 0) then sleep(0) end
end
return true, #Files .. " items, " .. Gfx.FormatSize(Done)
end
local function EraseMount(Mount)
for _, Name in ipairs(fs.list(Mount)) do
fs.delete(fs.combine(Mount, Name))
end
end
local function SelectedDrive()
local Item = DriveList and DriveList.Items[DriveList.Selected]
return Item and Item.Drive
end
local function RequireDisk()
local Drive = SelectedDrive()
if (not Drive or not Drive.HasData) then
Ui.Alert("DiskCopy", "Select a drive with a floppy disk in it.")
return nil
end
return Drive
end
local function Run(Task)
if (Busy) then return end
Busy = true
local Ok, Result, Detail = pcall(Task)
Busy = false
if (not Ok) then
Status = "Error: " .. tostring(Result)
elseif (Result == false) then
Status = tostring(Detail)
elseif (Result) then
Status = tostring(Result)
Progress = 1
end
ScanDrives()
Build()
end
local Actions = {
{ Text = "Duplicate", Task = function()
local Source = RequireDisk()
if (not Source) then return end
local Targets, Names = {}, {}
for _, Drive in ipairs(Drives) do
if (Drive ~= Source and Drive.HasData) then
table.insert(Targets, Drive)
table.insert(Names, (Drive.Label or "Untitled") .. " (" .. Drive.Name .. ")")
end
end
if (#Targets == 0) then
Ui.Alert("Duplicate", "Insert a second disk into another drive.")
return
end
local Index = Ui.Choose("Copy to", Names)
if (not Index) then return end
local Target = Targets[Index]
if (not Ui.Confirm("Duplicate", "Erase \"" .. (Target.Label or "Untitled") .. "\" and copy \"" .. (Source.Label or "Untitled") .. "\" onto it?", "Duplicate")) then return end
EraseMount(Target.Mount)
local Ok, Text = CopyTree(Source.Mount, Target.Mount)
if (Ok and Source.Label) then disk.setLabel(Target.Name, Source.Label) end
return Ok and ("Duplicated: " .. Text) or false, Text
end },
{ Text = "Back Up", Task = function()
local Source = RequireDisk()
if (not Source) then return end
local Name = (Source.Label or "disk"):gsub("[^%w%-_ ]", "") .. " " .. os.date("%Y-%m-%d %H%M")
local Target = fs.combine("/home/Backups", Name)
fs.makeDir(Target)
local Ok, Text = CopyTree(Source.Mount, Target)
return Ok and ("Backed up to /" .. Target) or false, Text
end },
{ Text = "Restore", Task = function()
local Target = RequireDisk()
if (not Target) then return end
local Folder = Ui.PickFile({ Title = "Restore from folder", Dir = "/home/Backups", Folders = true })
if (not Folder) then return end
if (not Ui.Confirm("Restore", "Erase the disk and copy " .. Folder .. " onto it?", "Restore")) then return end
EraseMount(Target.Mount)
local Ok, Text = CopyTree(Folder, Target.Mount)
return Ok and ("Restored: " .. Text) or false, Text
end },
{ Text = "Erase", Task = function()
local Target = RequireDisk()
if (not Target) then return end
if (not Ui.Confirm("Erase Disk", "Permanently erase everything on \"" .. (Target.Label or "Untitled") .. "\"?", "Erase")) then return end
EraseMount(Target.Mount)
return "Disk erased"
end },
{ Text = "Label", Task = function()
local Drive = SelectedDrive()
if (not Drive or not Drive.Present) then return end
local Label = Ui.Prompt("Label Disk", "New label:", Drive.Label or "")
if (Label == nil) then return end
disk.setLabel(Drive.Name, #Label > 0 and Label or nil)
return "Label updated"
end },
{ Text = "Installer", Task = function()
local Target = RequireDisk()
if (not Target) then return end
if (not Ui.Confirm("Installer Disk", "Write a BlockOS network installer to this disk? Any computer that boots it can install BlockOS.", "Write")) then return end
local Repo = Config.Settings().UpdateURL or Config.Repo
local Script = table.concat({
"-- BlockOS installer disk",
"term.clear() term.setCursorPos(1, 1)",
"print(\"BlockOS installer disk\")",
"write(\"Install BlockOS on this computer? (y/n) \")",
"if (read():lower():sub(1, 1) ~= \"y\") then return end",
"shell.run(\"wget\", \"run\", " .. string.format("%q", Repo .. "install.lua") .. ")"
}, "\n")
Config.WriteText(fs.combine(Target.Mount, "startup.lua"), Script .. "\n")
disk.setLabel(Target.Name, "BlockOS Installer")
return "Installer disk ready"
end },
{ Text = "Eject", Task = function()
local Drive = SelectedDrive()
if (Drive) then disk.eject(Drive.Name) end
end }
}
function Build()
Page:Clear()
local W, H = Page.W, Page.H
local Items = {}
for _, Drive in ipairs(Drives) do
local Text, Right
if (Drive.HasData) then
Text = (Drive.Label or "Untitled") .. " " .. Drive.Mount
Right = Drive.Capacity and (Gfx.FormatSize(Drive.Capacity - Drive.Free) .. " used") or (Gfx.FormatSize(Drive.Free) .. " free")
elseif (Drive.Audio) then
Text, Right = "\14 " .. Drive.Audio, "music"
else
Text, Right = "(empty)", ""
end
table.insert(Items, { Text = Drive.Name .. ": " .. Text, Icon = "\22", IconFg = Drive.HasData and "Accent" or "SubText", Right = Right, Drive = Drive })
end
local Selected = DriveList and DriveList.Selected or 1
DriveList = Page:Add(Ui.List.new({ X = 1, Y = 1, W = W, H = math.max(3, H - 8), Bg = "Panel" }))
DriveList:SetItems(Items)
DriveList.Selected = math.min(math.max(Selected, 1), #Items)
if (#Items == 0) then
Page:Add(Ui.Label.new({ X = 2, Y = 2, W = W - 2, H = 2, Bg = "Panel", Fg = "SubText", Text = "No disk drives attached. Place a disk drive next to the computer." }))
end
local X, Y = 2, H - 6
for _, Action in ipairs(Actions) do
local Width = #Action.Text + 2
if (X + Width > W + 1) then
X = 2
Y = Y + 2
end
Page:Add(Ui.Button.new({ X = X, Y = Y, W = Width, Text = Action.Text, Enabled = not Busy, OnClick = function() Run(Action.Task) end }))
X = X + Width + 1
end
Page:Add(Ui.Progress.new({ X = 2, Y = H - 1, W = W - 2, Value = function() return Progress end }))
Page:Add(Ui.Label.new({ X = 1, Y = H, W = W, Fg = "SubText", Text = function() return " " .. Status end }))
Page:Invalidate()
end
function Page:OnLayout()
Build()
end
function Page:OnEvent(Name)
if (not Busy and (Name == "disk" or Name == "disk_eject" or Name == "peripheral" or Name == "peripheral_detach")) then
ScanDrives()
Progress = 0
Build()
return true
end
return false
end
ScanDrives()
Page:Run()
]]
Files["blockos/apps/editor/app.cfg"] = [[
{ Name = "Editor", Description = "Code editor with Lua syntax highlighting.", Width = 46, Height = 15, Icon = { Lines = { ">", "\140\140 " }, Fg = "white", Bg = "purple" } }
]]
Files["blockos/apps/editor/main.lua"] = [=[
-- Editor: a code editor with Lua syntax highlighting, selection, undo, find and run.
local Ui = BlockOS.Require("ui")
local Gfx = BlockOS.Require("gfx")
local Config = BlockOS.Require("config")
local Apps = BlockOS.Require("apps")
local Args = { ... }
local Keywords = {}
for Word in ("and break do else elseif end false for function goto if in local nil not or repeat return then true until while"):gmatch("%S+") do
Keywords[Word] = true
end
local Builtins = {}
for Word in ([[print write read pairs ipairs require type tostring tonumber error assert pcall xpcall select next
setmetatable getmetatable rawget rawset rawequal load loadstring loadfile dofile unpack sleep table string math
os io fs term colors colours peripheral rednet http textutils shell multishell window keys turtle coroutine
parallel settings disk redstone rs vector gps paintutils bit32 bit _G _ENV self BlockOS commands pocket]]):gmatch("%S+") do
Builtins[Word] = true
end
local Hex = Gfx.Hex
local Doc = {
Lines = { "" },
X = 1,
Y = 1,
ScrollX = 0,
ScrollY = 0,
Path = nil,
Modified = false,
Anchor = nil,
UseTabs = false,
Undo = {},
Redo = {}
}
local States = {}
local ValidStates = 0
local Message, MessageTime = nil, 0
local Modifiers = Ui.Modifiers
local Find = ""
local Page = Ui.Page.new()
local Editor
local function Palette(Th)
if (Th.Dark) then
return {
Bg = colors.black, Text = colors.white, Keyword = colors.yellow, String = colors.red,
Comment = colors.green, Number = colors.purple, Builtin = colors.cyan, Operator = colors.lightGray,
Gutter = colors.gray, GutterFg = colors.lightGray, Selection = colors.blue
}
end
return {
Bg = colors.white, Text = colors.black, Keyword = colors.blue, String = colors.red,
Comment = colors.green, Number = colors.purple, Builtin = colors.cyan, Operator = colors.gray,
Gutter = colors.lightGray, GutterFg = colors.gray, Selection = colors.lightBlue
}
end
local function SetMessage(Text)
Message = Text
MessageTime = os.clock()
end
local function IsLua()
return not Doc.Path or Doc.Path:match("%.lua$") ~= nil or not Doc.Path:match("%.%w+$")
end
--
-- Highlighting
--
local function Highlight(Line, State, P)
local Colors = {}
local Length = #Line
local Index = 1
local function Paint(From, To, Color)
local Code = Hex[Color]
for Position = From, math.min(To, Length) do
Colors[Position] = Code
end
end
if (not IsLua()) then
Paint(1, Length, P.Text)
return table.concat(Colors), nil
end
if (State) then
local _, Finish = Line:find(State.Close, 1, true)
local Color = (State.Kind == "comment") and P.Comment or P.String
if (not Finish) then
Paint(1, Length, Color)
return table.concat(Colors), State
end
Paint(1, Finish, Color)
Index = Finish + 1
State = nil
end
while (Index <= Length) do
local Char = Line:sub(Index, Index)
if (Line:sub(Index, Index + 1) == "--") then
local Level = Line:match("^%[(=*)%[", Index + 2)
if (Level) then
local Close = "]" .. Level .. "]"
local _, Finish = Line:find(Close, Index + 4 + #Level, true)
if (not Finish) then
Paint(Index, Length, P.Comment)
return table.concat(Colors), { Kind = "comment", Close = Close }
end
Paint(Index, Finish, P.Comment)
Index = Finish + 1
else
Paint(Index, Length, P.Comment)
Index = Length + 1
end
elseif (Char == "\"" or Char == "'") then
local Position = Index + 1
while (Position <= Length) do
local Next = Line:sub(Position, Position)
if (Next == "\\") then
Position = Position + 2
elseif (Next == Char) then
break
else
Position = Position + 1
end
end
Paint(Index, Position, P.String)
Index = Position + 1
elseif (Char == "[" and Line:match("^%[=*%[", Index)) then
local Level = Line:match("^%[(=*)%[", Index)
local Close = "]" .. Level .. "]"
local _, Finish = Line:find(Close, Index + 2 + #Level, true)
if (not Finish) then
Paint(Index, Length, P.String)
return table.concat(Colors), { Kind = "string", Close = Close }
end
Paint(Index, Finish, P.String)
Index = Finish + 1
elseif (Char:match("%d") or (Char == "." and Line:sub(Index + 1, Index + 1):match("%d"))) then
local _, Finish = Line:find("^0[xX]%x+", Index)
if (not Finish) then _, Finish = Line:find("^%d*%.?%d*[eE][%+%-]?%d+", Index) end
if (not Finish) then _, Finish = Line:find("^%d*%.?%d*", Index) end
Paint(Index, Finish, P.Number)
Index = Finish + 1
elseif (Char:match("[%a_]")) then
local Start, Finish = Line:find("^[%w_]+", Index)
local Word = Line:sub(Start, Finish)
local Color = P.Text
if (Keywords[Word]) then
Color = P.Keyword
elseif (Builtins[Word]) then
Color = P.Builtin
end
Paint(Start, Finish, Color)
Index = Finish + 1
else
Paint(Index, Index, Char:match("[%+%-%*/%%%^#=<>~%.]") and P.Operator or P.Text)
Index = Index + 1
end
end
return table.concat(Colors), nil
end
local function Invalidate(FromLine)
ValidStates = math.min(ValidStates, FromLine - 1)
end
local function StateAt(Line, P)
if (ValidStates < 1) then
States[1] = nil
ValidStates = 1
end
while (ValidStates < Line) do
local _, Next = Highlight(Doc.Lines[ValidStates] or "", States[ValidStates], P)
ValidStates = ValidStates + 1
States[ValidStates] = Next
end
return States[Line]
end
--
-- Text model
--
local function TabWidth()
return 2
end
local function DisplayWidth(Line, Upto)
local Width = 0
for Index = 1, math.min(Upto, #Line) do
Width = Width + ((Line:sub(Index, Index) == "\t") and TabWidth() or 1)
end
return Width
end
local function CharFromDisplay(Line, Column)
local Width = 0
for Index = 1, #Line do
local Step = (Line:sub(Index, Index) == "\t") and TabWidth() or 1
if (Width + Step > Column) then return Index end
Width = Width + Step
end
return #Line + 1
end
local function Snapshot()
return { Text = table.concat(Doc.Lines, "\n"), X = Doc.X, Y = Doc.Y }
end
local function Restore(Shot)
Doc.Lines = {}
for Line in (Shot.Text .. "\n"):gmatch("(.-)\n") do
table.insert(Doc.Lines, Line)
end
if (#Doc.Lines == 0) then Doc.Lines = { "" } end
Doc.X, Doc.Y = Shot.X, Shot.Y
Doc.Anchor = nil
Invalidate(1)
end
local LastUndoKind, LastUndoTime = nil, 0
local function PushUndo(Kind)
local Now = os.clock()
if (Kind == "type" and LastUndoKind == "type" and Now - LastUndoTime < 1.5) then
LastUndoTime = Now
return
end
table.insert(Doc.Undo, Snapshot())
if (#Doc.Undo > 100) then table.remove(Doc.Undo, 1) end
Doc.Redo = {}
LastUndoKind, LastUndoTime = Kind, Now
end
local function Clamp()
Doc.Y = math.max(1, math.min(Doc.Y, #Doc.Lines))
Doc.X = math.max(1, math.min(Doc.X, #Doc.Lines[Doc.Y] + 1))
end
local function SelectionRange()
if (not Doc.Anchor) then return nil end
local AX, AY, BX, BY = Doc.Anchor.X, Doc.Anchor.Y, Doc.X, Doc.Y
if (AY > BY or (AY == BY and AX > BX)) then
AX, AY, BX, BY = BX, BY, AX, AY
end
if (AX == BX and AY == BY) then return nil end
return AX, AY, BX, BY
end
local function SelectedText()
local AX, AY, BX, BY = SelectionRange()
if (not AX) then return nil end
if (AY == BY) then
return Doc.Lines[AY]:sub(AX, BX - 1)
end
local Parts = { Doc.Lines[AY]:sub(AX) }
for Line = AY + 1, BY - 1 do
table.insert(Parts, Doc.Lines[Line])
end
table.insert(Parts, Doc.Lines[BY]:sub(1, BX - 1))
return table.concat(Parts, "\n")
end
local function DeleteSelection()
local AX, AY, BX, BY = SelectionRange()
Doc.Anchor = nil
if (not AX) then return false end
local Head = Doc.Lines[AY]:sub(1, AX - 1)
local Tail = Doc.Lines[BY]:sub(BX)
for _ = AY + 1, BY do
table.remove(Doc.Lines, AY + 1)
end
Doc.Lines[AY] = Head .. Tail
Doc.X, Doc.Y = AX, AY
Doc.Modified = true
Invalidate(AY)
return true
end
local function InsertText(Text)
DeleteSelection()
Text = Text:gsub("\r\n", "\n"):gsub("\r", "\n")
local Line = Doc.Lines[Doc.Y]
local Head, Tail = Line:sub(1, Doc.X - 1), Line:sub(Doc.X)
local Pieces = {}
for Piece in (Text .. "\n"):gmatch("(.-)\n") do
table.insert(Pieces, Piece)
end
if (#Pieces == 1) then
Doc.Lines[Doc.Y] = Head .. Pieces[1] .. Tail
Doc.X = Doc.X + #Pieces[1]
else
Doc.Lines[Doc.Y] = Head .. Pieces[1]
for Index = 2, #Pieces do
table.insert(Doc.Lines, Doc.Y + Index - 1, Pieces[Index])
end
local LastY = Doc.Y + #Pieces - 1
Doc.X = #Doc.Lines[LastY] + 1
Doc.Lines[LastY] = Doc.Lines[LastY] .. Tail
Invalidate(Doc.Y)
Doc.Y = LastY
end
Invalidate(Doc.Y)
Doc.Modified = true
end
local function ViewSize()
return Editor.W - Editor.Gutter, Editor.H
end
local function EnsureVisible()
Clamp()
local ViewW, ViewH = ViewSize()
if (Doc.Y <= Doc.ScrollY) then Doc.ScrollY = Doc.Y - 1 end
if (Doc.Y > Doc.ScrollY + ViewH) then Doc.ScrollY = Doc.Y - ViewH end
local Column = DisplayWidth(Doc.Lines[Doc.Y], Doc.X - 1)
if (Column < Doc.ScrollX) then Doc.ScrollX = math.max(0, Column - 4) end
if (Column >= Doc.ScrollX + ViewW - 1) then Doc.ScrollX = Column - ViewW + 2 end
end
local function SetLines(Text)
Doc.Lines = {}
for Line in (Text:gsub("\r\n", "\n") .. "\n"):gmatch("(.-)\n") do
table.insert(Doc.Lines, Line)
end
if (#Doc.Lines > 1 and Doc.Lines[#Doc.Lines] == "") then
table.remove(Doc.Lines)
end
if (#Doc.Lines == 0) then Doc.Lines = { "" } end
Doc.UseTabs = false
for _, Line in ipairs(Doc.Lines) do
if (Line:sub(1, 1) == "\t") then
Doc.UseTabs = true
break
end
end
Doc.X, Doc.Y, Doc.ScrollX, Doc.ScrollY = 1, 1, 0, 0
Doc.Anchor = nil
Doc.Undo, Doc.Redo = {}, {}
Doc.Modified = false
Invalidate(1)
end
local function UpdateTitle()
BlockOS.SetTitle((Doc.Modified and "\7 " or "") .. (Doc.Path and fs.getName(Doc.Path) or "Untitled"))
end
local function LoadFile(Path)
local Text = Config.ReadText(Path)
if (not Text) then
SetLines("")
Doc.Path = Path
SetMessage("New file")
else
SetLines(Text)
Doc.Path = Path
SetMessage("Opened " .. fs.getName(Path))
end
UpdateTitle()
end
local function SaveAs()
local Path = Ui.PickFile({ Save = true, Dir = Doc.Path and fs.getDir(Doc.Path) or "/home/Documents", Name = Doc.Path and fs.getName(Doc.Path) or "untitled.lua" })
if (not Path) then return false end
Doc.Path = Path
return true
end
local function Save()
if (not Doc.Path and not SaveAs()) then return false end
if (fs.isReadOnly(Doc.Path)) then
Ui.Alert("Save", "This location is read-only.")
return false
end
if (not Config.WriteText(Doc.Path, table.concat(Doc.Lines, "\n") .. "\n")) then
Ui.Alert("Save", "Could not write " .. Doc.Path)
return false
end
Doc.Modified = false
SetMessage("Saved " .. fs.getName(Doc.Path))
UpdateTitle()
return true
end
-- Returns true when it is safe to discard the current document.
local function ConfirmDiscard()
if (not Doc.Modified) then return true end
local Choice = Ui.Alert("Unsaved changes", "Save changes to " .. (Doc.Path and fs.getName(Doc.Path) or "Untitled") .. "?", { "Cancel", "Don't Save", "Save" })
if (Choice == 3) then return Save() end
return Choice == 2
end
local function Run()
if (not Save()) then return end
BlockOS.Launch(Doc.Path, {}, { Hold = true })
end
local function FindNext(Query, FromStart)
if (not Query or #Query == 0) then return false end
local StartY, StartX = Doc.Y, Doc.X
if (FromStart) then StartY, StartX = 1, 1 end
for Offset = 0, #Doc.Lines do
local Line = ((StartY - 1 + Offset) % #Doc.Lines) + 1
local From = (Offset == 0) and StartX or 1
local Start, Finish = Doc.Lines[Line]:find(Query, From, true)
if (Start) then
Doc.Anchor = { X = Start, Y = Line }
Doc.X, Doc.Y = Finish + 1, Line
EnsureVisible()
return true
end
end
SetMessage("Not found: " .. Query)
return false
end
local function Indentation(Line)
return Line:match("^[ \t]*")
end
local function IndentUnit()
return Doc.UseTabs and "\t" or " "
end
local function NewLine()
PushUndo("edit")
local Line = Doc.Lines[Doc.Y]:sub(1, Doc.X - 1)
local Indent = Indentation(Doc.Lines[Doc.Y])
local Trimmed = Line:gsub("%s+$", "")
if (Trimmed:match("then$") or Trimmed:match("do$") or Trimmed:match("else$") or Trimmed:match("repeat$")
or Trimmed:match("function%s*[%w_.:]*%b()$") or Trimmed:match("[{(]$")) then
Indent = Indent .. IndentUnit()
end
InsertText("\n" .. Indent)
end
local function Move(Key)
local Line = Doc.Lines[Doc.Y]
local _, ViewH = ViewSize()
if (Key == keys.left) then
if (Modifiers.Ctrl) then
local Before = Line:sub(1, Doc.X - 1)
local Position = Before:find("[%w_]+[^%w_]*$") or 1
Doc.X = (Position == Doc.X) and math.max(1, Doc.X - 1) or Position
elseif (Doc.X > 1) then
Doc.X = Doc.X - 1
elseif (Doc.Y > 1) then
Doc.Y = Doc.Y - 1
Doc.X = #Doc.Lines[Doc.Y] + 1
end
elseif (Key == keys.right) then
if (Modifiers.Ctrl) then
local _, Finish = Line:find("^[^%w_]*[%w_]*", Doc.X)
Doc.X = (Finish or #Line) + 1
elseif (Doc.X <= #Line) then
Doc.X = Doc.X + 1
elseif (Doc.Y < #Doc.Lines) then
Doc.Y = Doc.Y + 1
Doc.X = 1
end
elseif (Key == keys.up or Key == keys.down or Key == keys.pageUp or Key == keys.pageDown) then
local Column = DisplayWidth(Line, Doc.X - 1)
local Step = ({ [keys.up] = -1, [keys.down] = 1, [keys.pageUp] = -ViewH, [keys.pageDown] = ViewH })[Key]
Doc.Y = math.max(1, math.min(#Doc.Lines, Doc.Y + Step))
Doc.X = CharFromDisplay(Doc.Lines[Doc.Y], Column)
elseif (Key == keys.home) then
if (Modifiers.Ctrl) then
Doc.Y = 1
Doc.X = 1
else
local Indent = #Indentation(Line) + 1
Doc.X = (Doc.X == Indent) and 1 or Indent
end
elseif (Key == keys["end"]) then
if (Modifiers.Ctrl) then Doc.Y = #Doc.Lines end
Doc.X = #Doc.Lines[Doc.Y] + 1
else
return false
end
return true
end
--
-- Commands
--
local Clipboard = nil
local function Copy(Cut)
local Text = SelectedText()
if (not Text) then
Text = Doc.Lines[Doc.Y] .. "\n"
if (Cut) then
PushUndo("edit")
if (#Doc.Lines > 1) then
table.remove(Doc.Lines, Doc.Y)
else
Doc.Lines[1] = ""
end
Doc.Modified = true
Invalidate(Doc.Y)
end
elseif (Cut) then
PushUndo("edit")
DeleteSelection()
end
Clipboard = Text
BlockOS.TextClipboard = Text
SetMessage(Cut and "Cut" or "Copied")
end
local function PasteInternal()
local Text = BlockOS.TextClipboard or Clipboard
if (not Text) then return end
PushUndo("edit")
InsertText(Text)
end
local function UndoStep(Reverse)
local From, To = Doc.Undo, Doc.Redo
if (Reverse) then From, To = Doc.Redo, Doc.Undo end
local Shot = table.remove(From)
if (not Shot) then return end
table.insert(To, Snapshot())
Restore(Shot)
Doc.Modified = true
LastUndoKind = nil
end
local function ReplaceAll()
local Query = Ui.Prompt("Replace", "Find:", Find)
if (not Query or #Query == 0) then return end
local Replacement = Ui.Prompt("Replace", "Replace with:", "")
if (not Replacement) then return end
PushUndo("edit")
local Count = 0
local Pattern = Query:gsub("[%^%$%(%)%%%.%[%]%*%+%-%?]", "%%%0")
local Safe = Replacement:gsub("%%", "%%%%")
for Index, Line in ipairs(Doc.Lines) do
local Updated, Hits = Line:gsub(Pattern, Safe)
Doc.Lines[Index] = Updated
Count = Count + Hits
end
Find = Query
Doc.Modified = Count > 0 or Doc.Modified
Invalidate(1)
SetMessage("Replaced " .. Count .. " occurrence" .. (Count == 1 and "" or "s"))
end
local function GoToLine()
local Value = tonumber(Ui.Prompt("Go to Line", "Line number (1-" .. #Doc.Lines .. "):", tostring(Doc.Y)) or "")
if (Value) then
Doc.Y = math.floor(Value)
Doc.X = 1
Doc.Anchor = nil
EnsureVisible()
end
end
local function NewDocument()
if (not ConfirmDiscard()) then return end
SetLines("")
Doc.Path = nil
UpdateTitle()
end
local function OpenDocument()
if (not ConfirmDiscard()) then return end
local Path = Ui.PickFile({ Dir = Doc.Path and fs.getDir(Doc.Path) or "/home/Documents" })
if (Path) then LoadFile(Path) end
end
local function Close()
if (ConfirmDiscard()) then
BlockOS.Quit()
Page:Stop()
end
end
local function FileMenu()
local Options = { "New", "Open...", "Save", "Save As...", "-", "Run", "-", "Close" }
local OnDesktop = Doc.Path and Apps.HasShortcut(Doc.Path)
if (Doc.Path) then
table.insert(Options, 7, OnDesktop and "Remove from Desktop" or "Add to Desktop")
end
local Choice = Options[Ui.ContextMenu(1, 2, Options) or 0]
if (Choice == "New") then
NewDocument()
elseif (Choice == "Open...") then
OpenDocument()
elseif (Choice == "Save") then
Save()
elseif (Choice == "Save As...") then
if (SaveAs()) then Save() end
elseif (Choice == "Run") then
Run()
elseif (Choice == "Add to Desktop" or Choice == "Remove from Desktop") then
Apps.SetShortcut(Doc.Path, not OnDesktop)
elseif (Choice == "Close") then
Close()
end
end
local function EditMenu()
local Options = { "Undo", "Redo", "-", "Cut", "Copy", "Paste", "Select All", "-", "Find...", "Find Next", "Replace...", "Go to Line..." }
local Choice = Options[Ui.ContextMenu(7, 2, Options) or 0]
if (Choice == "Undo") then
UndoStep(false)
elseif (Choice == "Redo") then
UndoStep(true)
elseif (Choice == "Cut" or Choice == "Copy") then
Copy(Choice == "Cut")
elseif (Choice == "Paste") then
PasteInternal()
elseif (Choice == "Select All") then
Doc.Anchor = { X = 1, Y = 1 }
Doc.Y = #Doc.Lines
Doc.X = #Doc.Lines[Doc.Y] + 1
elseif (Choice == "Find...") then
local Query = Ui.Prompt("Find", "Search for:", Find)
if (Query) then
Find = Query
FindNext(Find)
end
elseif (Choice == "Find Next") then
FindNext(Find)
elseif (Choice == "Replace...") then
ReplaceAll()
elseif (Choice == "Go to Line...") then
GoToLine()
end
EnsureVisible()
end
--
-- Editor widget
--
local function DrawEditor(Self, T, Th)
local P = Palette(Th)
local Digits = #tostring(#Doc.Lines)
Self.Gutter = Digits + 2
local ViewW = Self.W - Self.Gutter
local AX, AY, BX, BY = SelectionRange()
for Row = 1, Self.H do
local LineIndex = Doc.ScrollY + Row
local Y = Self.Y + Row - 1
local Line = Doc.Lines[LineIndex]
if (not Line) then
Gfx.Fill(T, Self.X, Y, Self.Gutter, 1, P.Gutter)
Gfx.Fill(T, Self.X + Self.Gutter, Y, ViewW, 1, P.Bg)
else
local Number = Gfx.Pad(tostring(LineIndex), Digits, "right") .. " "
Gfx.Write(T, Self.X, Y, " " .. Number, (LineIndex == Doc.Y) and P.Text or P.GutterFg, P.Gutter)
local Colors = Highlight(Line, StateAt(LineIndex, P), P)
local Chars, Fg, Bg = {}, {}, {}
local BgCode, SelCode = Hex[P.Bg], Hex[P.Selection]
for Index = 1, #Line do
local Char = Line:sub(Index, Index)
local Selected = AX and (LineIndex > AY or (LineIndex == AY and Index >= AX)) and (LineIndex < BY or (LineIndex == BY and Index < BX))
local Code = Colors:sub(Index, Index)
if (Code == "") then Code = Hex[P.Text] end
if (Char == "\t") then
for _ = 1, TabWidth() do
table.insert(Chars, " ")
table.insert(Fg, Code)
table.insert(Bg, Selected and SelCode or BgCode)
end
else
table.insert(Chars, Char)
table.insert(Fg, Code)
table.insert(Bg, Selected and SelCode or BgCode)
end
end
local Text = table.concat(Chars):sub(Doc.ScrollX + 1, Doc.ScrollX + ViewW)
local FgText = table.concat(Fg):sub(Doc.ScrollX + 1, Doc.ScrollX + ViewW)
local BgText = table.concat(Bg):sub(Doc.ScrollX + 1, Doc.ScrollX + ViewW)
local Padding = ViewW - #Text
T.setCursorPos(Self.X + Self.Gutter, Y)
T.blit(Text .. string.rep(" ", Padding), FgText .. string.rep(Hex[P.Text], Padding), BgText .. string.rep(BgCode, Padding))
end
end
end
local function EditorCursor(Self)
local Row = Doc.Y - Doc.ScrollY
if (Row < 1 or Row > Self.H) then return nil end
local Column = DisplayWidth(Doc.Lines[Doc.Y], Doc.X - 1) - Doc.ScrollX
if (Column < 0 or Column >= Self.W - Self.Gutter) then return nil end
local Th = BlockOS.Require("theme").Get()
return Self.X + Self.Gutter + Column, Self.Y + Row - 1, Palette(Th).Text
end
local function PositionFromMouse(Self, X, Y)
local LineIndex = math.max(1, math.min(#Doc.Lines, Doc.ScrollY + Y - Self.Y + 1))
local Column = math.max(0, X - Self.X - Self.Gutter + Doc.ScrollX)
return CharFromDisplay(Doc.Lines[LineIndex], Column), LineIndex
end
local function EditorEvent(Self, Name, A, B, C)
if (Name == "mouse_click" or Name == "mouse_drag") then
local X, Y = PositionFromMouse(Self, B, C)
if (Name == "mouse_click") then
if (A == 2) then
EditMenu()
return true
end
Doc.Anchor = Modifiers.Shift and (Doc.Anchor or { X = Doc.X, Y = Doc.Y }) or { X = X, Y = Y }
end
Doc.X, Doc.Y = X, Y
EnsureVisible()
return true
elseif (Name == "mouse_up") then
if (Doc.Anchor and Doc.Anchor.X == Doc.X and Doc.Anchor.Y == Doc.Y) then
Doc.Anchor = nil
end
return true
elseif (Name == "mouse_scroll") then
Doc.ScrollY = math.max(0, math.min(Doc.ScrollY + A * 3, math.max(0, #Doc.Lines - Self.H)))
return true
elseif (Name == "char") then
if (Modifiers.Ctrl) then return true end
PushUndo((A == " ") and "space" or "type")
InsertText(A)
EnsureVisible()
return true
elseif (Name == "paste") then
PushUndo("edit")
InsertText(A)
EnsureVisible()
return true
elseif (Name == "key") then
if (Modifiers.Ctrl) then
if (A == keys.s) then
Save()
elseif (A == keys.o) then
OpenDocument()
elseif (A == keys.n) then
NewDocument()
elseif (A == keys.z) then
UndoStep(false)
elseif (A == keys.y) then
UndoStep(true)
elseif (A == keys.c or A == keys.x) then
Copy(A == keys.x)
elseif (A == keys.a) then
Doc.Anchor = { X = 1, Y = 1 }
Doc.Y = #Doc.Lines
Doc.X = #Doc.Lines[Doc.Y] + 1
elseif (A == keys.f) then
local Query = Ui.Prompt("Find", "Search for:", Find)
if (Query) then
Find = Query
FindNext(Find)
end
elseif (A == keys.h) then
ReplaceAll()
elseif (A == keys.g) then
GoToLine()
elseif (A == keys.d) then
PushUndo("edit")
table.insert(Doc.Lines, Doc.Y + 1, Doc.Lines[Doc.Y])
Doc.Y = Doc.Y + 1
Doc.Modified = true
Invalidate(Doc.Y)
elseif (A == keys.q or A == keys.w) then
Close()
elseif (not Move(A)) then
return false
elseif (not Modifiers.Shift) then
Doc.Anchor = nil
end
EnsureVisible()
return true
end
local Before = { X = Doc.X, Y = Doc.Y }
if (Move(A)) then
if (Modifiers.Shift) then
Doc.Anchor = Doc.Anchor or Before
else
Doc.Anchor = nil
end
elseif (A == keys.enter or A == keys.numPadEnter) then
NewLine()
elseif (A == keys.backspace) then
PushUndo("delete")
if (not DeleteSelection()) then
if (Doc.X > 1) then
local Line = Doc.Lines[Doc.Y]
local Unit = IndentUnit()
if (Line:sub(1, Doc.X - 1):match("^%s+$") and Line:sub(Doc.X - #Unit, Doc.X - 1) == Unit) then
Doc.Lines[Doc.Y] = Line:sub(1, Doc.X - #Unit - 1) .. Line:sub(Doc.X)
Doc.X = Doc.X - #Unit
else
Doc.Lines[Doc.Y] = Line:sub(1, Doc.X - 2) .. Line:sub(Doc.X)
Doc.X = Doc.X - 1
end
elseif (Doc.Y > 1) then
local Previous = Doc.Lines[Doc.Y - 1]
Doc.Lines[Doc.Y - 1] = Previous .. Doc.Lines[Doc.Y]
table.remove(Doc.Lines, Doc.Y)
Doc.Y = Doc.Y - 1
Doc.X = #Previous + 1
end
Doc.Modified = true
Invalidate(Doc.Y)
end
elseif (A == keys.delete) then
PushUndo("delete")
if (not DeleteSelection()) then
local Line = Doc.Lines[Doc.Y]
if (Doc.X <= #Line) then
Doc.Lines[Doc.Y] = Line:sub(1, Doc.X - 1) .. Line:sub(Doc.X + 1)
elseif (Doc.Y < #Doc.Lines) then
Doc.Lines[Doc.Y] = Line .. Doc.Lines[Doc.Y + 1]
table.remove(Doc.Lines, Doc.Y + 1)
end
Doc.Modified = true
Invalidate(Doc.Y)
end
elseif (A == keys.tab) then
PushUndo("edit")
InsertText(IndentUnit())
elseif (A == keys.f3) then
FindNext(Find)
elseif (A == keys.f5) then
Run()
else
return false
end
EnsureVisible()
return true
end
return false
end
function Page:OnLayout(W, H)
self:Clear()
self:Add(Ui.Panel.new({ X = 1, Y = 1, W = W, H = 1, Bg = "Panel" }))
self:Add(Ui.Button.new({ X = 1, Y = 1, Text = "File", Bg = "Panel", OnClick = function() FileMenu() end }))
self:Add(Ui.Button.new({ X = 7, Y = 1, Text = "Edit", Bg = "Panel", OnClick = function() EditMenu() end }))
self:Add(Ui.Button.new({ X = W - 13, Y = 1, W = 6, Text = "Save", Bg = "Panel", OnClick = function() Save() end }))
self:Add(Ui.Button.new({ X = W - 6, Y = 1, W = 7, Text = "Run \16", Style = "primary", OnClick = function() Run() end }))
Editor = self:Add(Ui.Canvas.new({
X = 1, Y = 2, W = W, H = H - 2, Gutter = 3, Focusable = true, KeepFocus = true,
OnDraw = DrawEditor,
OnEvent = EditorEvent,
OnCursor = EditorCursor
}))
self:Add(Ui.Canvas.new({
X = 1, Y = H, W = W, H = 1,
OnDraw = function(Self, T, Th)
local Left = " Ln " .. Doc.Y .. ", Col " .. Doc.X .. (Doc.Modified and " \7 Edited" or "")
local Right = (IsLua() and "Lua" or "Text") .. " "
if (Message and os.clock() - MessageTime < 4) then
Right = Message .. " " .. Right
end
Gfx.Write(T, Self.X, Self.Y, Gfx.Pad(Left, Self.W - #Right) .. Right, Th.SubText, Th.Panel)
end
}))
self:SetFocus(Editor)
EnsureVisible()
end
function Page:OnEvent(Name, A)
if (Name == "blockos_close") then
Close()
return true
elseif (Name == "blockos_open" and A) then
if (ConfirmDiscard()) then LoadFile(A) end
return true
end
return false
end
function Page:OnTerminate()
Close()
end
local BaseHandle = Page.Handle
function Page:Handle(...)
BaseHandle(self, ...)
if (Doc.Modified ~= self.LastModified) then
self.LastModified = Doc.Modified
UpdateTitle()
end
end
BlockOS.SetCloseHandler(true)
if (Args[1]) then
LoadFile(shell and shell.resolve(Args[1]) or Args[1])
else
UpdateTitle()
end
Page:SetInterval(1, function()
return Message ~= nil
end)
Page:Run()
]=]
Files["blockos/apps/finder/app.cfg"] = [[
{ Name = "Finder", Description = "Browse files, folders, disks and apps.", Width = 44, Height = 14, Icon = { Lines = { " :)", "\140\140\140" }, Fg = "white", Bg = "blue" } }
]]
Files["blockos/apps/finder/main.lua"] = [[
-- Finder: file manager with favourites, disks and an Applications view.
local Ui = BlockOS.Require("ui")
local Gfx = BlockOS.Require("gfx")
local Apps = BlockOS.Require("apps")
local Config = BlockOS.Require("config")
local Image = BlockOS.Require("image")
local Wallpaper = BlockOS.Require("wallpaper")
local Args = { ... }
local AppsPath = "apps:"
local Printable = { txt = true, md = true, log = true }
local Current = Args[1] or "/home"
local Back, Forward = {}, {}
local Page = Ui.Page.new()
local Sidebar, Files, PathLabel, Status
BlockOS.Clipboard = BlockOS.Clipboard or {}
local Kinds = {
lua = { Icon = "\4", Fg = colors.yellow, Name = "Lua script" },
txt = { Icon = "\7", Fg = colors.lightGray, Name = "Text" },
md = { Icon = "\7", Fg = colors.lightGray, Name = "Markdown" },
dfpwm = { Icon = "\14", Fg = colors.red, Name = "DFPWM audio" },
rec = { Icon = "\7", Fg = colors.red, Name = "Recording" },
nfp = { Icon = "\127", Fg = colors.pink, Name = "Paint image" },
bimg = { Icon = "\127", Fg = colors.magenta, Name = "Brocreate picture" },
ccmd = { Icon = "@", Fg = colors.orange, Name = "ccmd page" },
json = { Icon = "\7", Fg = colors.orange, Name = "JSON" },
cfg = { Icon = "\7", Fg = colors.orange, Name = "Config" }
}
local function Normalize(Path)
if (Path == AppsPath) then return Path end
return "/" .. fs.combine(Path, "")
end
local function KindOf(Path)
if (fs.isDir(Path)) then
return { Icon = "\16", Fg = colors.lightBlue, Name = "Folder" }
end
local Extension = Path:match("%.([%w]+)$")
return Kinds[Extension and Extension:lower() or ""] or { Icon = "\7", Fg = colors.gray, Name = "File" }
end
local function DisplayName(Path)
if (Path == AppsPath) then return "Applications" end
if (Path == "/") then return "Computer" end
if (Path == "/home") then return "Home" end
return fs.getName(Path)
end
local function UniqueName(Dir, Name)
if (not fs.exists(fs.combine(Dir, Name))) then return Name end
local Base, Extension = Name:match("^(.-)(%.[^%.]*)$")
if (not Base or Base == "") then
Base, Extension = Name, ""
end
local Index = 1
while (true) do
local Candidate = Base .. " copy" .. (Index > 1 and (" " .. Index) or "") .. Extension
if (not fs.exists(fs.combine(Dir, Candidate))) then return Candidate end
Index = Index + 1
end
end
local function SidebarItems()
local Items = {
{ Text = "Favorites", Header = true },
{ Text = "Home", Path = "/home", Icon = "\127", IconFg = colors.lightBlue },
{ Text = "Documents", Path = "/home/Documents", Icon = "\7", IconFg = colors.lightBlue },
{ Text = "Downloads", Path = "/home/Downloads", Icon = "\25", IconFg = colors.lightBlue },
{ Text = "Music", Path = "/home/Music", Icon = "\14", IconFg = colors.lightBlue },
{ Text = "Recordings", Path = "/home/Recordings", Icon = "\7", IconFg = colors.red },
{ Text = "Pictures", Path = "/home/Pictures", Icon = "\127", IconFg = colors.magenta },
{ Text = "Locations", Header = true },
{ Text = "Apps", Path = AppsPath, Icon = "\4", IconFg = colors.purple },
{ Text = "Computer", Path = "/", Icon = "\8", IconFg = colors.gray }
}
for _, Side in ipairs(peripheral.getNames()) do
if (peripheral.getType(Side) == "drive" and disk.hasData(Side)) then
local Mount = disk.getMountPath(Side)
table.insert(Items, { Text = disk.getLabel(Side) or Mount, Path = "/" .. Mount, Icon = "\22", IconFg = colors.gray, Side = Side })
end
end
return Items
end
local Refresh
local function Navigate(Path, SkipHistory)
Path = Normalize(Path)
if (Path ~= AppsPath and not fs.isDir(Path)) then
BlockOS.Open(Path)
return
end
if (not SkipHistory and Path ~= Current) then
table.insert(Back, Current)
Forward = {}
end
Current = Path
Refresh()
end
local function OpenItem(Item)
if (not Item) then return end
if (Item.App) then
BlockOS.Launch(Item.App.ID)
elseif (Item.IsDir) then
Navigate(Item.Path)
else
BlockOS.Open(Item.Path)
end
end
function Refresh()
local Items = {}
if (Current == AppsPath) then
for _, App in ipairs(Apps.List()) do
table.insert(Items, {
Text = App.Name,
Icon = Gfx.Pad(App.Icon.Lines[1], 3),
IconFg = App.Icon.Bg,
Right = App.System and "System" or "Installed",
App = App
})
end
else
if (not fs.isDir(Current)) then Current = "/" end
local Names = fs.list(Current)
table.sort(Names, function(A, B) return A:lower() < B:lower() end)
local Dirs, Plain = {}, {}
for _, Name in ipairs(Names) do
local Path = fs.combine(Current, Name)
local Kind = KindOf(Path)
local Entry = { Text = Name, Icon = Kind.Icon, IconFg = Kind.Fg, Path = "/" .. Path, IsDir = fs.isDir(Path) }
if (Entry.IsDir) then
Entry.Right = "--"
table.insert(Dirs, Entry)
else
Entry.Right = Gfx.FormatSize(fs.getSize(Path))
table.insert(Plain, Entry)
end
end
for _, Entry in ipairs(Dirs) do table.insert(Items, Entry) end
for _, Entry in ipairs(Plain) do table.insert(Items, Entry) end
end
Files:SetItems(Items)
local SideItems = SidebarItems()
Sidebar:SetItems(SideItems, true)
Sidebar.Selected = 0
for Index, Item in ipairs(SideItems) do
if (Item.Path == Current) then Sidebar.Selected = Index end
end
PathLabel.Text = DisplayName(Current)
if (Current == AppsPath) then
Status.Text = #Items .. " apps"
else
Status.Text = #Items .. " items, " .. Gfx.FormatSize(fs.getFreeSpace(Current)) .. " free" .. (fs.isReadOnly(Current) and ", read-only" or "")
end
BlockOS.SetTitle(DisplayName(Current))
Page:Invalidate()
end
local function Paste(Dir)
local Clip = BlockOS.Clipboard
if (not Clip.Path or not fs.exists(Clip.Path) or Dir == AppsPath) then return end
local Target = fs.combine(Dir, UniqueName(Dir, fs.getName(Clip.Path)))
local Ok, Err = pcall(function()
if (Clip.Cut) then
fs.move(Clip.Path, Target)
Apps.MoveShortcuts(Clip.Path, Target)
else
fs.copy(Clip.Path, Target)
end
end)
if (not Ok) then
Ui.Alert("Paste failed", tostring(Err))
end
if (Clip.Cut) then BlockOS.Clipboard = {} end
Refresh()
end
local function NewItem(Folder)
if (Current == AppsPath or fs.isReadOnly(Current)) then return end
local Name = Ui.Prompt(Folder and "New Folder" or "New File", "Name:", Folder and "untitled folder" or "untitled.txt")
if (not Name or #Name == 0) then return end
local Path = fs.combine(Current, UniqueName(Current, Name))
if (Folder) then
fs.makeDir(Path)
else
local Handle = fs.open(Path, "w")
if (Handle) then Handle.close() end
end
Refresh()
end
local function DeleteItem(Item)
if (not Item or not Item.Path or fs.isReadOnly(Item.Path)) then return end
if (Ui.Confirm("Delete", "Delete \"" .. fs.getName(Item.Path) .. "\"? This cannot be undone.", "Delete")) then
local Ok, Err = pcall(fs.delete, Item.Path)
if (Ok) then
Apps.MoveShortcuts(Item.Path, nil)
else
Ui.Alert("Delete failed", tostring(Err))
end
Refresh()
end
end
local function GetInfo(Item)
if (Item.App) then
local App = Item.App
Ui.Alert(App.Name, (App.Description or "No description.") .. "\n\nID: " .. App.ID .. "\nLocation: " .. App.Dir)
return
end
local Path = Item.Path
local Lines = { "Kind: " .. KindOf(Path).Name, "Where: " .. fs.getDir(Path) }
if (not Item.IsDir) then
table.insert(Lines, "Size: " .. Gfx.FormatSize(fs.getSize(Path)))
end
if (fs.attributes) then
local Ok, Attributes = pcall(fs.attributes, Path)
if (Ok and Attributes.modified and Attributes.modified > 0) then
table.insert(Lines, "Modified: " .. os.date("%Y-%m-%d %H:%M", math.floor(Attributes.modified / 1000)))
end
end
table.insert(Lines, fs.isReadOnly(Path) and "Read-only" or "Writable")
Ui.Alert(fs.getName(Path), table.concat(Lines, "\n"))
end
local function SetWallpaper(Path)
if (not Image.Load(Path)) then
Ui.Alert("Set as Wallpaper", fs.getName(Path) .. " is not a picture BlockOS can read.")
return
end
local Settings = Config.Settings()
Settings.Wallpaper = Wallpaper.Picture.ID
Settings.WallpaperImage = "/" .. fs.combine(Path, "")
Wallpaper.Invalidate()
Config.Save("settings")
end
local function AppMenu(Item, X, Y)
local App = Item.App
local Pinned = Apps.IsPinned(App.ID)
local OnDesktop = Apps.HasShortcut(Apps.AppShortcut(App.ID))
local Options = { "Open", "Open Full Screen", "-", Pinned and "Remove from Dock" or "Keep in Dock", OnDesktop and "Remove from Desktop" or "Add to Desktop", "Add to Login Items", "Get Info" }
if (not App.System) then
table.insert(Options, "-")
table.insert(Options, "Uninstall")
end
local Choice = Options[Ui.ContextMenu(X, Y, Options) or 0]
if (Choice == "Open") then
BlockOS.Launch(App.ID)
elseif (Choice == "Open Full Screen") then
BlockOS.Launch(App.ID, {}, { Mode = "fullscreen" })
elseif (Choice == "Keep in Dock" or Choice == "Remove from Dock") then
Apps.SetPinned(App.ID, not Pinned)
elseif (Choice == "Add to Desktop" or Choice == "Remove from Desktop") then
Apps.SetShortcut(Apps.AppShortcut(App.ID), not OnDesktop)
elseif (Choice == "Add to Login Items") then
local Settings = Config.Settings()
table.insert(Settings.StartupApps, App.ID)
Config.Save("settings")
elseif (Choice == "Get Info") then
GetInfo(Item)
elseif (Choice == "Uninstall") then
if (Ui.Confirm("Uninstall", "Remove " .. App.Name .. " from this computer?", "Uninstall")) then
Apps.Uninstall(App.ID)
Refresh()
end
end
end
local function FileMenu(Item, X, Y)
local ReadOnly = Current == AppsPath or fs.isReadOnly(Current)
local HasClip = BlockOS.Clipboard.Path ~= nil
if (not Item) then
local Options = {
{ Text = "New Folder", Disabled = ReadOnly },
{ Text = "New File", Disabled = ReadOnly },
{ Text = "Paste", Disabled = ReadOnly or not HasClip },
"-",
{ Text = "Open in Terminal" },
{ Text = "Refresh" }
}
local Index = Ui.ContextMenu(X, Y, Options)
local Choice = Index and Options[Index].Text
if (Choice == "New Folder") then
NewItem(true)
elseif (Choice == "New File") then
NewItem(false)
elseif (Choice == "Paste") then
Paste(Current)
elseif (Choice == "Open in Terminal") then
BlockOS.Launch("terminal", { Current })
elseif (Choice == "Refresh") then
Refresh()
end
return
end
if (Item.App) then
AppMenu(Item, X, Y)
return
end
local Locked = fs.isReadOnly(Item.Path)
local OnDesktop = Apps.HasShortcut(Item.Path)
local Options = {
{ Text = "Open" },
{ Text = "Edit", Disabled = Item.IsDir },
{ Text = "Run", Disabled = Item.IsDir },
"-",
{ Text = "Rename", Disabled = Locked },
{ Text = "Duplicate", Disabled = ReadOnly },
{ Text = "Copy" },
{ Text = "Cut", Disabled = Locked },
{ Text = "Paste", Disabled = ReadOnly or not HasClip },
"-",
{ Text = OnDesktop and "Remove from Desktop" or "Add to Desktop" },
{ Text = "Get Info" },
{ Text = "Delete", Disabled = Locked }
}
local Extension = (fs.getName(Item.Path):match("%.(%w+)$") or ""):lower()
if (not Item.IsDir and Printable[Extension]) then
table.insert(Options, 4, { Text = "Print" })
end
if (not Item.IsDir and Image.IsImage(Item.Path)) then
table.insert(Options, 4, { Text = "Set as Wallpaper" })
end
local Index = Ui.ContextMenu(X, Y, Options)
local Choice = Index and Options[Index].Text
if (Choice == "Open") then
OpenItem(Item)
elseif (Choice == "Edit") then
BlockOS.Launch(Apps.EditorFor(Item.Path), { Item.Path })
elseif (Choice == "Run") then
BlockOS.Launch(Item.Path, {}, { Hold = true })
elseif (Choice == "Print") then
BlockOS.Launch("print", { Item.Path })
elseif (Choice == "Set as Wallpaper") then
SetWallpaper(Item.Path)
elseif (Choice == "Add to Desktop" or Choice == "Remove from Desktop") then
Apps.SetShortcut(Item.Path, not OnDesktop)
elseif (Choice == "Rename") then
local Name = Ui.Prompt("Rename", "New name:", fs.getName(Item.Path))
if (Name and #Name > 0 and Name ~= fs.getName(Item.Path)) then
local Target = fs.combine(fs.getDir(Item.Path), Name)
if (fs.exists(Target)) then
Ui.Alert("Rename", "An item with that name already exists.")
else
fs.move(Item.Path, Target)
Apps.MoveShortcuts(Item.Path, Target)
end
end
Refresh()
elseif (Choice == "Duplicate") then
fs.copy(Item.Path, fs.combine(Current, UniqueName(Current, fs.getName(Item.Path))))
Refresh()
elseif (Choice == "Copy" or Choice == "Cut") then
BlockOS.Clipboard = { Path = Item.Path, Cut = Choice == "Cut" }
elseif (Choice == "Paste") then
Paste(Item.IsDir and Item.Path or Current)
elseif (Choice == "Get Info") then
GetInfo(Item)
elseif (Choice == "Delete") then
DeleteItem(Item)
end
end
local function GoUp()
if (Current == AppsPath or Current == "/") then return end
Navigate("/" .. fs.getDir(Current))
end
function Page:OnLayout(W, H)
self:Clear()
local SideW = (W >= 34) and 12 or 0
self:Add(Ui.Panel.new({ X = 1, Y = 1, W = W, H = 1, Bg = "Panel" }))
self:Add(Ui.Button.new({ X = 1, Y = 1, W = 3, Text = "\17", Bg = "Panel", OnClick = function()
if (#Back > 0) then
table.insert(Forward, Current)
Current = table.remove(Back)
Refresh()
end
end }))
self:Add(Ui.Button.new({ X = 4, Y = 1, W = 3, Text = "\16", Bg = "Panel", OnClick = function()
if (#Forward > 0) then
table.insert(Back, Current)
Current = table.remove(Forward)
Refresh()
end
end }))
self:Add(Ui.Button.new({ X = 7, Y = 1, W = 3, Text = "\24", Bg = "Panel", OnClick = GoUp }))
PathLabel = self:Add(Ui.Label.new({ X = 11, Y = 1, W = W - 18, Text = "", Bg = "Panel", Fg = "PanelFg" }))
self:Add(Ui.Button.new({ X = W - 6, Y = 1, W = 3, Text = "+", Bg = "Panel", OnClick = function() NewItem(true) end }))
self:Add(Ui.Button.new({ X = W - 3, Y = 1, W = 3, Text = "\15", Bg = "Panel", OnClick = function() FileMenu(nil, W - 3, 2) end }))
Sidebar = self:Add(Ui.List.new({ X = 1, Y = 2, W = SideW, H = H - 2, Bg = "Panel", Focusable = false, SingleClick = true, Visible = SideW > 0 }))
Sidebar.RenderItem = function(_, T, Th, Item, X, Y, Width, Selected, RowBg, RowFg)
if (Item.Header) then
Gfx.Write(T, X, Y, Gfx.Pad(Item.Text, Width), Th.SubText, Th.Panel)
return
end
Gfx.Write(T, X, Y, " " .. (Item.Icon or " ") .. " " .. Gfx.Truncate(Item.Text, Width - 4), Selected and RowFg or Th.PanelFg, RowBg)
if (not Selected) then
Gfx.Write(T, X + 1, Y, Item.Icon or " ", Item.IconFg or Th.PanelFg, RowBg)
end
end
Sidebar.OnActivate = function(_, _, Item)
if (Item.Header) then return end
Navigate(Item.Path)
end
Sidebar.OnContext = function(_, _, Item, X, Y)
if (Item and Item.Side) then
local Choice = Ui.ContextMenu(X, Y, { "Open", "Eject" })
if (Choice == 1) then
Navigate(Item.Path)
elseif (Choice == 2) then
disk.eject(Item.Side)
end
end
end
Files = self:Add(Ui.List.new({ X = SideW + 1, Y = 2, W = W - SideW, H = H - 2 }))
Files.OnActivate = function(_, _, Item) OpenItem(Item) end
Files.OnContext = function(_, _, Item, X, Y) FileMenu(Item, X, Y) end
Status = self:Add(Ui.Label.new({ X = 1, Y = H, W = W, Text = "", Bg = "Panel", Fg = "SubText", Align = "center" }))
self:SetFocus(Files)
Refresh()
end
function Page:OnEvent(Name, A)
if (Name == "key") then
if (A == keys.backspace) then
GoUp()
return true
elseif (A == keys.delete) then
DeleteItem(Files.Items[Files.Selected])
return true
end
elseif (Name == "disk" or Name == "disk_eject" or Name == "blockos_apps" or Name == "peripheral" or Name == "peripheral_detach") then
Refresh()
return true
elseif (Name == "blockos_open") then
Navigate(A)
return true
end
return false
end
Current = Normalize(Current)
Page:Run()
]]
Files["blockos/apps/launchpad/app.cfg"] = [[
{ Name = "Launchpad", Description = "All of your apps in one place.", Builtin = "launchpad", Icon = { Lines = { "\7\7\7", "\7\7\7" }, Fg = "white", Bg = "gray" } }
]]
Files["blockos/apps/messages/app.cfg"] = [[
{ Name = "Messages", Description = "Chat with other BlockOS computers over rednet, with encrypted rooms.", Width = 46, Height = 15, Single = true, Icon = { Lines = { "...", "/ " }, Fg = "white", Bg = "green" } }
]]
Files["blockos/apps/messages/main.lua"] = [[
-- Messages: Enchat-style chat rooms between BlockOS computers over rednet.
-- Rooms with a key are end-to-end encrypted; /dm opens a direct conversation.
local Ui = BlockOS.Require("ui")
local Gfx = BlockOS.Require("gfx")
local Chat = BlockOS.Require("chat")
local Page = Ui.Page.new()
local ActiveID = nil
local Scroll = 0
local Rooms, Input, MessageView
local NickColors = { colors.red, colors.orange, colors.green, colors.cyan, colors.blue, colors.purple, colors.magenta, colors.brown }
local function NickColor(Nick)
local Hash = 0
for Index = 1, #Nick do
Hash = (Hash * 17 + Nick:byte(Index)) % 251
end
return NickColors[Hash % #NickColors + 1]
end
local function Active()
return ActiveID and Chat.Rooms[ActiveID]
end
local function RoomTitle(Room)
if (Room.Target) then return "@" .. Room.Name:gsub("^@", "") end
return "#" .. Room.Name
end
local function SetActive(ID)
ActiveID = ID
Scroll = 0
local Room = Active()
if (Room) then
Room.Unread = 0
BlockOS.SetTitle("Messages \7 " .. RoomTitle(Room))
end
end
local function System(Text)
local Room = Active()
if (not Room) then return end
table.insert(Room.Messages, { Kind = "system", Text = Text, Time = os.epoch("utc") })
end
local Refresh
local function JoinPrompt()
local Name = Ui.Prompt("Join Room", "Room name:", "")
if (not Name or #Name == 0) then return end
Name = Name:gsub("^#", ""):gsub("%s+", "-"):lower()
local Key = Ui.Prompt("Join Room", "Room key (leave empty for a public room):", "", "\7")
if (Key == nil) then return end
local Room = Chat.Join(Name, Key)
SetActive(Room.ID)
Refresh()
end
local Help = {
"/join [key] join or create a room",
"/leave leave this room",
"/nick change your nickname",
"/me send an action",
"/dm start a direct message",
"/who list people online",
"/clear clear this room's history"
}
local function RunCommand(Text)
local Command, Rest = Text:match("^/(%S+)%s*(.*)$")
Command = (Command or ""):lower()
local Room = Active()
if (Command == "join") then
local Name, Key = Rest:match("^#?(%S+)%s*(.*)$")
if (not Name) then
JoinPrompt()
return
end
local NewRoom = Chat.Join(Name:lower(), Key)
SetActive(NewRoom.ID)
elseif (Command == "leave") then
if (Room) then
Chat.Leave(Room.ID)
SetActive(Chat.Order[1])
end
elseif (Command == "nick") then
if (#Rest > 0) then
Chat.SetNick(Rest:sub(1, 20))
System("You are now known as " .. Rest:sub(1, 20))
Chat.Presence()
end
elseif (Command == "me") then
if (Room and #Rest > 0) then Chat.Send(Room.ID, Rest, "me") end
elseif (Command == "dm") then
local Target = tonumber(Rest)
if (not Target or Target == os.getComputerID()) then
System("Usage: /dm ")
return
end
local NewRoom = Chat.Join("@" .. Target, nil, Target)
SetActive(NewRoom.ID)
elseif (Command == "who") then
if (Room) then
local Names = {}
for _, User in ipairs(Chat.OnlineUsers(Room)) do
table.insert(Names, User.Nick .. " (#" .. User.ID .. ")")
end
System((#Names > 0) and ("Online: " .. table.concat(Names, ", ")) or "Nobody else is here yet.")
end
elseif (Command == "clear") then
if (Room) then
Room.Messages = {}
Chat.Save()
end
else
for _, Line in ipairs(Help) do
System(Line)
end
end
Refresh()
end
local function Send(Text)
Text = Text:gsub("^%s+", ""):gsub("%s+$", "")
if (#Text == 0) then return end
if (Text:sub(1, 1) == "/") then
RunCommand(Text)
return
end
local Room = Active()
if (not Room) then return end
local Ok, Err = Chat.Send(Room.ID, Text)
if (not Ok) then
System("Not sent: " .. tostring(Err))
end
Scroll = 0
Refresh()
end
-- Builds wrapped display lines: each is a list of { Text, Color } segments.
local function RenderLines(Room, Width, Th)
local Lines = {}
for _, Message in ipairs(Room.Messages) do
local Stamp = os.date("%H:%M", math.floor((Message.Time or 0) / 1000)) .. " "
local Prefix, PrefixColor, Body, BodyColor
if (Message.Kind == "system") then
Prefix, PrefixColor, Body, BodyColor = "", Th.SubText, Message.Text, Th.SubText
elseif (Message.Kind == "me") then
Prefix, PrefixColor, Body, BodyColor = "* " .. Message.Nick .. " ", NickColor(Message.Nick), Message.Text, Th.Text
else
Prefix = Message.Nick .. ": "
PrefixColor = Message.Self and Th.Accent or NickColor(Message.Nick)
Body, BodyColor = Message.Text, Th.Text
end
if (Message.Failed) then
Body = Body .. " (!)"
end
local Wrapped = Gfx.Wrap(Prefix .. Body, Width - #Stamp)
for Index, Line in ipairs(Wrapped) do
local Segments = { { Index == 1 and Stamp or string.rep(" ", #Stamp), Th.SubText } }
if (Index == 1 and #Prefix > 0) then
table.insert(Segments, { Line:sub(1, #Prefix), PrefixColor })
table.insert(Segments, { Line:sub(#Prefix + 1), BodyColor })
else
table.insert(Segments, { Line, BodyColor })
end
table.insert(Lines, Segments)
end
end
return Lines
end
function Refresh()
local Items = {}
local Selected = 0
for Index, ID in ipairs(Chat.Order) do
local Room = Chat.Rooms[ID]
table.insert(Items, {
Text = RoomTitle(Room),
Icon = Room.Key and "\4" or " ",
IconFg = colors.orange,
Right = (Room.Unread > 0) and tostring(Room.Unread) or "",
ID = ID
})
if (ID == ActiveID) then Selected = Index end
end
if (Rooms) then
Rooms:SetItems(Items, true)
Rooms.Selected = Selected
end
Page:Invalidate()
end
function Page:OnLayout(W, H)
self:Clear()
local SideW = (W >= 36) and 13 or 0
Rooms = self:Add(Ui.List.new({ X = 1, Y = 1, W = SideW, H = H - 1, Bg = "Panel", SingleClick = true, Focusable = false, Visible = SideW > 0 }))
Rooms.OnActivate = function(_, _, Item)
SetActive(Item.ID)
Refresh()
end
Rooms.OnContext = function(_, _, Item, X, Y)
local Options = { "Join Room..." }
if (Item) then
table.insert(Options, "Leave " .. Item.Text)
end
local Choice = Ui.ContextMenu(X, Y, Options)
if (Choice == 1) then
JoinPrompt()
elseif (Choice == 2) then
Chat.Leave(Item.ID)
SetActive(Chat.Order[1])
Refresh()
end
end
self:Add(Ui.Button.new({ X = 1, Y = H, W = math.max(SideW, 1), Text = "+ Join", Bg = "Panel", Visible = SideW > 0, OnClick = JoinPrompt }))
local MainX = SideW + 1
local MainW = W - SideW
self:Add(Ui.Canvas.new({
X = MainX, Y = 1, W = MainW, H = 1,
OnDraw = function(Self, T, Th)
local Room = Active()
Gfx.Fill(T, Self.X, Self.Y, Self.W, 1, Th.Panel)
if (not Room) then return end
local Online = #Chat.OnlineUsers(Room)
local Title = " " .. RoomTitle(Room) .. (Room.Key and " \4" or "")
local Info = Room.Target and "direct " or (Online .. " online ")
Gfx.Write(T, Self.X, Self.Y, Gfx.Truncate(Title, Self.W - #Info), Th.PanelFg, Th.Panel)
Gfx.Write(T, Self.X + Self.W - #Info, Self.Y, Info, Th.SubText, Th.Panel)
end,
OnEvent = function(_, Name)
if (Name ~= "mouse_click" or SideW > 0) then return false end
local Names = {}
for _, ID in ipairs(Chat.Order) do
table.insert(Names, RoomTitle(Chat.Rooms[ID]))
end
table.insert(Names, "+ Join Room...")
local Index = Ui.Choose("Rooms", Names)
if (Index == #Names) then
JoinPrompt()
elseif (Index) then
SetActive(Chat.Order[Index])
Refresh()
end
return true
end
}))
MessageView = self:Add(Ui.Canvas.new({
X = MainX, Y = 2, W = MainW, H = H - 2,
OnDraw = function(Self, T, Th)
local Room = Active()
if (not Chat.OpenModems()) then
Gfx.Write(T, Self.X + 1, Self.Y, Gfx.Truncate("No modem attached. Attach a wireless or ender modem.", Self.W - 2), colors.red, Th.WindowBg)
end
if (not Room) then return end
local Lines = RenderLines(Room, Self.W - 1, Th)
local MaxScroll = math.max(0, #Lines - Self.H)
Scroll = math.min(Scroll, MaxScroll)
local First = #Lines - Self.H - Scroll + 1
for Row = 1, Self.H do
local Segments = Lines[First + Row - 1]
if (Segments) then
local X = Self.X + 1
for _, Segment in ipairs(Segments) do
Gfx.Write(T, X, Self.Y + Row - 1, Segment[1], Segment[2], Th.WindowBg)
X = X + #Segment[1]
end
end
end
if (#Room.Messages == 0) then
Gfx.Center(T, Self.Y + math.floor(Self.H / 2), "Say hello to " .. RoomTitle(Room), Th.SubText, Th.WindowBg, Self.X, Self.W)
end
end,
OnEvent = function(_, Name, A)
if (Name == "mouse_scroll") then
Scroll = math.max(0, Scroll - A * 2)
return true
end
return false
end
}))
Input = self:Add(Ui.Input.new({ X = MainX, Y = H, W = MainW, Placeholder = "Message (/help for commands)", MaxLength = 400 }))
Input.OnSubmit = function(Self, Value)
Self:SetValue("")
Send(Value)
end
self:SetFocus(Input)
Refresh()
end
function Page:OnEvent(Name, A)
if (Name == "blockos_chat") then
local Room = Active()
if (Room and A == Room.ID and BlockOS.IsFocused()) then
Room.Unread = 0
end
Refresh()
return true
elseif (Name == "peripheral" or Name == "peripheral_detach") then
return true
elseif (Name == "key" and (A == keys.pageUp or A == keys.pageDown)) then
Scroll = math.max(0, Scroll + ((A == keys.pageUp) and 5 or -5))
return true
end
return false
end
Chat.Load()
Chat.OpenModems()
if (not Chat.Nick) then
local Nick = Ui.Prompt("Welcome to Messages", "Choose a nickname others will see:", Chat.GetNick())
if (Nick and #Nick > 0) then
Chat.SetNick(Nick:sub(1, 20))
else
Chat.SetNick(Chat.GetNick())
end
end
SetActive(Chat.Order[1])
Page:SetInterval(15, function() return true end)
Page:Run()
]]
Files["blockos/apps/network/app.cfg"] = [[
{ Name = "Network", Description = "Find nearby BlockOS computers on a GPS map.", Width = 46, Height = 16, Single = true, Icon = { Lines = { "\7 \7", " \2 " }, Fg = "white", Bg = "cyan" } }
]]
Files["blockos/apps/network/main.lua"] = [[
-- Network: finds nearby BlockOS computers over rednet and places them on a map using GPS.
local Ui = BlockOS.Require("ui")
local Gfx = BlockOS.Require("gfx")
local Config = BlockOS.Require("config")
local Presence = BlockOS.Require("presence")
local Page = Ui.Page.new()
local Settings = Config.Settings()
local ScanSeconds = 3
local RescanSeconds = 30
local RelocateSeconds = 60
local Scales = { 1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000 }
-- Terminal cells are about one and a half times taller than they are wide.
local CellAspect = 1.5
local State = {
Computers = {},
Self = nil,
Status = "idle",
Message = nil,
Selected = nil,
Scale = nil,
ScanTimer = nil
}
local Markers = {}
local Build
local function Distance(Entry)
if (not (State.Self and Entry.Position)) then return math.huge end
return (Presence.Bearing(State.Self, Entry.Position))
end
local function Sorted()
local List = {}
for _, Entry in pairs(State.Computers) do
table.insert(List, Entry)
end
table.sort(List, function(A, B)
local DA, DB = Distance(A), Distance(B)
if (DA ~= DB) then return DA < DB end
return A.ID < B.ID
end)
return List
end
local function FormatDistance(Blocks)
if (Blocks == math.huge) then return "?" end
if (Blocks < 1000) then return string.format("%dm", math.floor(Blocks + .5)) end
return string.format("%.1fkm", Blocks / 1000)
end
local function Scan()
if (State.Status ~= "idle") then return end
if (not Presence.OpenModems()) then
State.Message = "No modem attached"
Build()
return
end
State.Message = nil
if (not Presence.LocatedAt or os.clock() - Presence.LocatedAt > RelocateSeconds) then
State.Status = "locating"
Page:Draw()
Presence.Locate(2)
end
State.Self = Presence.Position
State.Computers = {}
State.Status = "scanning"
Presence.Scan()
State.ScanTimer = os.startTimer(ScanSeconds)
Build()
end
local function StatusText()
if (State.Status == "locating") then return "Locating with GPS..." end
if (State.Status == "scanning") then return "Scanning..." end
if (State.Message) then return State.Message end
local Count = 0
for _ in pairs(State.Computers) do Count = Count + 1 end
local Text = (Count == 1) and "1 computer nearby" or (Count .. " computers nearby")
if (State.Self) then
return Text .. string.format(" \7 you %d, %d, %d", State.Self.X, State.Self.Y, State.Self.Z)
end
return Text .. " \7 no GPS"
end
local function DetailText()
local Entry = State.Selected and State.Computers[State.Selected]
if (not Entry) then
return Settings.NetworkVisible ~= false and "Select a computer for details." or "This computer is hidden from others."
end
local Parts = { "#" .. Entry.ID .. " " .. Presence.Name(Entry) }
if (Entry.User) then table.insert(Parts, Entry.User) end
if (State.Self and Entry.Position) then
local Blocks, Direction = Presence.Bearing(State.Self, Entry.Position)
table.insert(Parts, FormatDistance(Blocks) .. " " .. Direction)
local Rise = Entry.Position.Y - State.Self.Y
table.insert(Parts, "y" .. ((Rise >= 0) and "+" or "") .. Rise)
elseif (Entry.Position) then
table.insert(Parts, string.format("%d, %d, %d", Entry.Position.X, Entry.Position.Y, Entry.Position.Z))
else
table.insert(Parts, "no GPS")
end
return table.concat(Parts, " \7 ")
end
-- The point the map is centred on: this computer, or the middle of everyone else.
local function MapCentre()
if (State.Self) then return State.Self end
local SumX, SumZ, Count = 0, 0, 0
for _, Entry in pairs(State.Computers) do
if (Entry.Position) then
SumX = SumX + Entry.Position.X
SumZ = SumZ + Entry.Position.Z
Count = Count + 1
end
end
if (Count == 0) then return nil end
return { X = SumX / Count, Y = 0, Z = SumZ / Count }
end
local function FitScale(Centre, W, H)
local Needed = 1
for _, Entry in pairs(State.Computers) do
if (Entry.Position) then
local DX = math.abs(Entry.Position.X - Centre.X) / math.max(1, W / 2 - 2)
local DZ = math.abs(Entry.Position.Z - Centre.Z) / math.max(1, (H / 2 - 1) * CellAspect)
Needed = math.max(Needed, DX, DZ)
end
end
for _, Scale in ipairs(Scales) do
if (Scale >= Needed) then return Scale end
end
return Scales[#Scales]
end
local function Zoom(Step)
local Current = State.Scale or State.FittedScale or 1
local Index = 1
for Position, Scale in ipairs(Scales) do
if (Scale <= Current) then Index = Position end
end
State.Scale = Scales[math.max(1, math.min(#Scales, Index + Step))]
end
local function DrawMap(Self, T, Th)
local W, H = Self.W, Self.H
Gfx.Fill(T, Self.X, Self.Y, W, H, Th.Panel)
Markers = {}
local Centre = MapCentre()
if (not Centre) then
local Hint = "No GPS signal. Build a GPS cluster (four computers running gps host) to see where everyone is."
if (State.Message) then
Hint = "Attach a wireless or ender modem to find nearby computers."
end
local Lines = Gfx.Wrap(Hint, W - 2)
local Top = Self.Y + math.max(0, math.floor((H - #Lines) / 2))
for Row, Line in ipairs(Lines) do
if (Top + Row - 1 < Self.Y + H) then
Gfx.Center(T, Top + Row - 1, Line, Th.SubText, Th.Panel, Self.X, W)
end
end
return
end
State.FittedScale = FitScale(Centre, W, H)
local Scale = State.Scale or State.FittedScale
local MidX = Self.X + math.floor(W / 2)
local MidY = Self.Y + math.floor(H / 2)
for Row = 0, H - 1 do
for Column = 0, W - 1 do
if ((Self.X + Column - MidX) % 6 == 0 and (Self.Y + Row - MidY) % 3 == 0) then
Gfx.Write(T, Self.X + Column, Self.Y + Row, "+", Th.Border, Th.Panel)
end
end
end
Gfx.Write(T, Self.X, Self.Y, "\24N", Th.SubText, Th.Panel)
local Legend = "\140\140\140\140\140\140 " .. FormatDistance(Scale * 6)
Gfx.Write(T, Self.X, Self.Y + H - 1, Legend, Th.SubText, Th.Panel)
local function Plot(Position, Char, Fg, Bg, Label, ID)
local X = MidX + math.floor((Position.X - Centre.X) / Scale + .5)
local Y = MidY + math.floor((Position.Z - Centre.Z) / (Scale * CellAspect) + .5)
local Outside = X < Self.X or X >= Self.X + W or Y < Self.Y or Y >= Self.Y + H
X = math.max(Self.X, math.min(Self.X + W - 1, X))
Y = math.max(Self.Y, math.min(Self.Y + H - 1, Y))
Gfx.Write(T, X, Y, Outside and "\4" or Char, Outside and Th.SubText or Fg, Bg or Th.Panel)
if (Label and X + 2 + #Label <= Self.X + W) then
Gfx.Write(T, X + 1, Y, " " .. Label, Th.SubText, Th.Panel)
end
if (ID) then table.insert(Markers, { X = X, Y = Y, ID = ID }) end
end
for _, Entry in ipairs(Sorted()) do
if (Entry.Position) then
local Selected = Entry.ID == State.Selected
Plot(Entry.Position, Entry.Turtle and "T" or "\7", Selected and Th.AccentFg or Th.Text, Selected and Th.Accent or nil, Gfx.Truncate(Presence.Name(Entry), 10), Entry.ID)
end
end
if (State.Self) then
Plot(State.Self, "\2", Th.Accent, nil, "You")
end
end
function Build()
Page:Clear()
local W, H = Page.W, Page.H
Page:Add(Ui.Panel.new({ X = 1, Y = 1, W = W, H = 1, Bg = "Panel" }))
Page:Add(Ui.Button.new({ X = 1, Y = 1, W = 6, Text = "Scan", Style = "primary", OnClick = Scan }))
Page:Add(Ui.Label.new({ X = 8, Y = 1, W = W - 14, Text = StatusText, Fg = "SubText", Bg = "Panel" }))
Page:Add(Ui.Button.new({ X = W - 5, Y = 1, W = 3, Text = "-", Bg = "Panel", OnClick = function() Zoom(1) end }))
Page:Add(Ui.Button.new({ X = W - 2, Y = 1, W = 3, Text = "+", Bg = "Panel", OnClick = function() Zoom(-1) end }))
local BodyH = math.max(3, H - 3)
local ListW = (W >= 38) and 16 or 0
local MapW = W - ((ListW > 0) and ListW + 1 or 0)
Page:Add(Ui.Canvas.new({
X = 1, Y = 2, W = MapW, H = BodyH,
OnDraw = DrawMap,
OnEvent = function(Self, Name, A, B, C)
if (Name == "mouse_click") then
for _, Marker in ipairs(Markers) do
if (math.abs(Marker.X - B) <= 1 and Marker.Y == C) then
State.Selected = Marker.ID
Build()
return true
end
end
elseif (Name == "mouse_scroll") then
Zoom(A)
return true
end
return false
end
}))
if (ListW > 0) then
local Items = {}
local Selected = 0
for Index, Entry in ipairs(Sorted()) do
table.insert(Items, {
Text = Presence.Name(Entry), ID = Entry.ID,
Icon = Entry.Turtle and "T" or "\7", IconFg = Entry.Position and "Accent" or "SubText",
Right = FormatDistance(Distance(Entry))
})
if (Entry.ID == State.Selected) then Selected = Index end
end
local List = Page:Add(Ui.List.new({ X = MapW + 2, Y = 2, W = ListW, H = BodyH, Items = Items, Bg = "Panel" }))
List.Selected = Selected
List.OnSelect = function(_, _, Item)
if (Item) then State.Selected = Item.ID end
end
if (#Items == 0 and State.Status == "idle") then
Page:Add(Ui.Label.new({ X = MapW + 3, Y = 3, W = ListW - 2, H = 4, Fg = "SubText", Bg = "Panel", Text = "No other computers answered." }))
end
end
Page:Add(Ui.Label.new({ X = 1, Y = H - 1, W = W, Text = DetailText }))
Page:Add(Ui.Toggle.new({
X = 1, Y = H, Value = Settings.NetworkVisible ~= false,
OnChange = function(_, Value)
Settings.NetworkVisible = Value
Config.Save("settings")
end
}))
Page:Add(Ui.Label.new({ X = 6, Y = H, W = W - 5, Text = "Let other computers find this one", Fg = "SubText" }))
Page:Invalidate()
end
function Page:OnLayout()
Build()
end
function Page:OnEvent(Name, A, B, C)
if (Name == "rednet_message" and C == Presence.Protocol) then
local Entry = Presence.Parse(A, B)
if (not Entry or A == os.getComputerID()) then return false end
State.Computers[Entry.ID] = Entry
Build()
return true
elseif (Name == "timer" and A == State.ScanTimer) then
State.ScanTimer = nil
State.Status = "idle"
Build()
return true
elseif (Name == "peripheral" or Name == "peripheral_detach") then
Build()
return true
end
return false
end
Page:SetInterval(RescanSeconds, function()
Scan()
return false
end)
Page:Resize()
Scan()
Page:Run()
]]
Files["blockos/apps/notes/app.cfg"] = [[
{ Name = "Notes", Description = "Quick notes and plain text files with word wrap and autosave.", Width = 44, Height = 14, Single = true, Icon = { Lines = { "\140\140\140", "\140\140 " }, Fg = "black", Bg = "yellow" } }
]]
Files["blockos/apps/notes/main.lua"] = [[
-- Notes: a notepad with a list of notes, word wrap and autosave. Also opens .txt files.
local Ui = BlockOS.Require("ui")
local Gfx = BlockOS.Require("gfx")
local Config = BlockOS.Require("config")
local Args = { ... }
local Folder = "/home/Notes"
local SaveDelay = 1.5
local Page = Ui.Page.new()
local Notes = {}
local Doc = nil
local Rows = {}
local Layout = { SideW = 0, EX = 1, EY = 2, EW = 10, EH = 5 }
local Build
local function SplitLines(Text)
local Lines = {}
for Line in ((Text or ""):gsub("\r", "") .. "\n"):gmatch("(.-)\n") do
table.insert(Lines, Line)
end
if (#Lines == 0) then Lines[1] = "" end
return Lines
end
local function TitleOf(Lines, Path)
for _, Line in ipairs(Lines) do
local Trimmed = Line:match("^%s*(.-)%s*$")
if (#Trimmed > 0) then return Trimmed end
end
return Path and fs.getName(Path):gsub("%.txt$", "") or "New Note"
end
local function Modified(Path)
local Ok, Attributes = pcall(fs.attributes, Path)
return Ok and Attributes and Attributes.modified or 0
end
local function LoadNotes()
if (not fs.exists(Folder)) then fs.makeDir(Folder) end
Notes = {}
for _, Name in ipairs(fs.list(Folder)) do
local Path = "/" .. fs.combine(Folder, Name)
if (Name:match("%.txt$") and not fs.isDir(Path)) then
local Lines = SplitLines(Config.ReadText(Path))
table.insert(Notes, { Path = Path, Title = TitleOf(Lines, Path), Modified = Modified(Path) })
end
end
table.sort(Notes, function(A, B) return A.Modified > B.Modified end)
if (Doc and Doc.Path and not Doc.Path:find("^" .. Folder .. "/")) then
table.insert(Notes, 1, { Path = Doc.Path, Title = fs.getName(Doc.Path), External = true })
end
end
local function WordCount()
local Count = 0
for _, Line in ipairs(Doc.Lines) do
for _ in Line:gmatch("%S+") do Count = Count + 1 end
end
return Count
end
local function Save()
if (not Doc or not Doc.Dirty) then return true end
local Ok = Config.WriteText(Doc.Path, table.concat(Doc.Lines, "\n"))
if (Ok) then
Doc.Dirty = false
for _, Note in ipairs(Notes) do
if (Note.Path == Doc.Path and not Note.External) then
Note.Title = TitleOf(Doc.Lines, Doc.Path)
end
end
end
return Ok
end
local function Open(Path)
Save()
Doc = { Path = Path, Lines = SplitLines(Config.ReadText(Path)), X = 1, Y = 1, Scroll = 0, GoalX = nil, Dirty = false }
BlockOS.SetTitle(fs.getName(Path))
end
local function NewNote()
Save()
local Path = "/" .. fs.combine(Folder, "Note " .. os.date("%Y-%m-%d %H%M%S") .. ".txt")
Config.WriteText(Path, "")
Open(Path)
LoadNotes()
end
--
-- Word wrap
--
-- Splits a line into visual rows, breaking after the last space that fits.
local function Segments(Text, Width)
local Starts = { 1 }
local Position = 1
while (#Text - Position + 1 >= Width) do
local Chunk = Text:sub(Position, Position + Width - 1)
local Break = Chunk:match("^.*() ")
if (Break and Break > 1) then
Position = Position + Break
else
Position = Position + Width
end
table.insert(Starts, Position)
end
return Starts
end
local function Rewrap()
Rows = {}
local Width = math.max(4, Layout.EW)
for Index, Line in ipairs(Doc.Lines) do
local Starts = Segments(Line, Width)
for Part, Start in ipairs(Starts) do
local Stop = Starts[Part + 1] and (Starts[Part + 1] - 1) or (#Line + 1)
table.insert(Rows, { Line = Index, Start = Start, Stop = Stop, Last = Starts[Part + 1] == nil })
end
end
end
local function CursorRow()
for Index, Row in ipairs(Rows) do
if (Row.Line == Doc.Y and Doc.X >= Row.Start and Doc.X <= Row.Stop) then
return Index, Row
end
end
return #Rows, Rows[#Rows]
end
local function EnsureVisible()
local Index = CursorRow()
if (Index <= Doc.Scroll) then
Doc.Scroll = Index - 1
elseif (Index > Doc.Scroll + Layout.EH) then
Doc.Scroll = Index - Layout.EH
end
Doc.Scroll = math.max(0, math.min(Doc.Scroll, math.max(0, #Rows - 1)))
end
local function Changed()
Doc.Dirty = true
Doc.LastEdit = os.clock()
Doc.GoalX = nil
Rewrap()
EnsureVisible()
end
--
-- Editing
--
local function Insert(Text)
local Line = Doc.Lines[Doc.Y]
Doc.Lines[Doc.Y] = Line:sub(1, Doc.X - 1) .. Text .. Line:sub(Doc.X)
Doc.X = Doc.X + #Text
Changed()
end
local function NewLine()
local Line = Doc.Lines[Doc.Y]
local Indent = Line:match("^([%-%*] )") or ""
Doc.Lines[Doc.Y] = Line:sub(1, Doc.X - 1)
table.insert(Doc.Lines, Doc.Y + 1, Indent .. Line:sub(Doc.X))
Doc.Y = Doc.Y + 1
Doc.X = #Indent + 1
Changed()
end
local function Backspace()
if (Doc.X > 1) then
local Line = Doc.Lines[Doc.Y]
Doc.Lines[Doc.Y] = Line:sub(1, Doc.X - 2) .. Line:sub(Doc.X)
Doc.X = Doc.X - 1
elseif (Doc.Y > 1) then
local Previous = Doc.Lines[Doc.Y - 1]
Doc.Lines[Doc.Y - 1] = Previous .. Doc.Lines[Doc.Y]
table.remove(Doc.Lines, Doc.Y)
Doc.Y = Doc.Y - 1
Doc.X = #Previous + 1
else
return
end
Changed()
end
local function Delete()
local Line = Doc.Lines[Doc.Y]
if (Doc.X <= #Line) then
Doc.Lines[Doc.Y] = Line:sub(1, Doc.X - 1) .. Line:sub(Doc.X + 1)
elseif (Doc.Y < #Doc.Lines) then
Doc.Lines[Doc.Y] = Line .. Doc.Lines[Doc.Y + 1]
table.remove(Doc.Lines, Doc.Y + 1)
else
return
end
Changed()
end
local function MoveVertical(Step)
local Index, Row = CursorRow()
local Target = Rows[Index + Step]
if (not Target) then
Doc.X = (Step < 0) and 1 or (#Doc.Lines[Doc.Y] + 1)
return
end
Doc.GoalX = Doc.GoalX or (Doc.X - Row.Start)
Doc.Y = Target.Line
Doc.X = math.min(Target.Start + Doc.GoalX, Target.Stop)
end
local function MoveHorizontal(Step)
Doc.GoalX = nil
if (Step < 0) then
if (Doc.X > 1) then
Doc.X = Doc.X - 1
elseif (Doc.Y > 1) then
Doc.Y = Doc.Y - 1
Doc.X = #Doc.Lines[Doc.Y] + 1
end
elseif (Doc.X <= #Doc.Lines[Doc.Y]) then
Doc.X = Doc.X + 1
elseif (Doc.Y < #Doc.Lines) then
Doc.Y = Doc.Y + 1
Doc.X = 1
end
end
local function HandleKey(Key)
local Ctrl = Ui.Modifiers.Ctrl
if (Ctrl and Key == keys.s) then
Save()
elseif (Ctrl and Key == keys.n) then
NewNote()
Build()
return true
elseif (Key == keys.enter or Key == keys.numPadEnter) then
NewLine()
elseif (Key == keys.backspace) then
Backspace()
elseif (Key == keys.delete) then
Delete()
elseif (Key == keys.tab) then
Insert(" ")
elseif (Key == keys.left or Key == keys.right) then
MoveHorizontal(Key == keys.left and -1 or 1)
elseif (Key == keys.up or Key == keys.down) then
MoveVertical(Key == keys.up and -1 or 1)
elseif (Key == keys.pageUp or Key == keys.pageDown) then
for _ = 1, Layout.EH - 1 do
MoveVertical(Key == keys.pageUp and -1 or 1)
end
elseif (Key == keys.home or Key == keys["end"]) then
local _, Row = CursorRow()
Doc.X = (Key == keys.home) and Row.Start or Row.Stop
Doc.GoalX = nil
else
return false
end
EnsureVisible()
return true
end
--
-- Layout
--
local function SelectNote(Path)
Open(Path)
Rewrap()
Build()
end
local function DeleteCurrent()
if (not Doc) then return end
local Title = TitleOf(Doc.Lines, Doc.Path)
if (not Ui.Confirm("Delete Note", "Delete \"" .. Gfx.Truncate(Title, 20) .. "\"?", "Delete")) then return end
fs.delete(Doc.Path)
Doc = nil
LoadNotes()
if (#Notes == 0) then
NewNote()
else
Open(Notes[1].Path)
end
Rewrap()
Build()
end
local Editor
function Build()
Page:Clear()
local W, H = Page.W, Page.H
Layout.SideW = (W >= 36) and 14 or 0
Layout.EX = Layout.SideW + 2
Layout.EY = 2
Layout.EW = W - Layout.SideW - 2
Layout.EH = H - 2
Rewrap()
EnsureVisible()
Page:Add(Ui.Canvas.new({
X = 1, Y = 1, W = W, H = 1,
OnDraw = function(Self, T, Th)
Gfx.Fill(T, Self.X, Self.Y, Self.W, 1, Th.Panel)
end
}))
Page:Add(Ui.Button.new({ X = 1, Y = 1, Text = " + New ", Style = "plain", Bg = "Panel", OnClick = function()
NewNote()
Rewrap()
Build()
end }))
Page:Add(Ui.Button.new({ X = 9, Y = 1, Text = " Delete ", Style = "plain", Bg = "Panel", OnClick = DeleteCurrent }))
if (W >= 34) then
Page:Add(Ui.Button.new({ X = 18, Y = 1, Text = " Print ", Style = "plain", Bg = "Panel", OnClick = function()
Save()
if (Doc.Path) then BlockOS.Launch("print", { Doc.Path }) end
end }))
end
if (Layout.SideW == 0) then
Page:Add(Ui.Button.new({ X = W - 7, Y = 1, W = 7, Text = "Notes \31", Style = "plain", Bg = "Panel", OnClick = function()
local Names = {}
for _, Note in ipairs(Notes) do table.insert(Names, Note.Title) end
local Index = Ui.Choose("Notes", Names)
if (Index) then SelectNote(Notes[Index].Path) end
end }))
end
if (Layout.SideW > 0) then
local Items = {}
local Selected = 1
for Index, Note in ipairs(Notes) do
local Title = (Doc and Note.Path == Doc.Path) and TitleOf(Doc.Lines, Doc.Path) or Note.Title
table.insert(Items, { Text = Title, Path = Note.Path })
if (Doc and Note.Path == Doc.Path) then Selected = Index end
end
local List = Page:Add(Ui.List.new({ X = 1, Y = 2, W = Layout.SideW, H = H - 1, Bg = "Panel", SingleClick = true }))
List:SetItems(Items)
List.Selected = Selected
List.OnActivate = function(_, _, Item)
if (Item and (not Doc or Item.Path ~= Doc.Path)) then
SelectNote(Item.Path)
else
Page:SetFocus(Editor)
end
end
end
Editor = Page:Add(Ui.Canvas.new({
X = Layout.EX, Y = Layout.EY, W = Layout.EW, H = Layout.EH, Focusable = true, KeepFocus = true,
OnDraw = function(Self, T, Th)
for Offset = 1, Self.H do
local Row = Rows[Doc.Scroll + Offset]
local Text = ""
local Fg = Th.Text
if (Row) then
Text = Doc.Lines[Row.Line]:sub(Row.Start, Row.Last and #Doc.Lines[Row.Line] or Row.Stop)
if (Row.Line == 1) then Fg = Th.Accent end
end
Gfx.Write(T, Self.X, Self.Y + Offset - 1, Gfx.Pad(Text, Self.W), Fg, Th.WindowBg)
end
end,
OnCursor = function(Self, Th)
local Index, Row = CursorRow()
local Y = Index - Doc.Scroll
if (Y < 1 or Y > Self.H) then return nil end
return Self.X + Doc.X - Row.Start, Self.Y + Y - 1, Th.Text
end,
OnEvent = function(Self, Name, A, B, C)
if (Name == "char") then
Insert(A)
return true
elseif (Name == "paste") then
Insert((A:gsub("[\r\n]", " ")))
return true
elseif (Name == "key") then
return HandleKey(A)
elseif (Name == "mouse_click" or Name == "mouse_drag") then
local Row = Rows[Doc.Scroll + C - Self.Y + 1] or Rows[#Rows]
Doc.Y = Row.Line
Doc.X = math.max(Row.Start, math.min(Row.Start + B - Self.X, Row.Stop))
Doc.GoalX = nil
return true
elseif (Name == "mouse_scroll") then
Doc.Scroll = math.max(0, math.min(Doc.Scroll + A * 2, math.max(0, #Rows - Self.H)))
return true
end
return false
end
}))
Page:Add(Ui.Label.new({
X = Layout.EX, Y = H, W = Layout.EW, Fg = "SubText",
Text = function()
local Words = WordCount()
return (Doc.Dirty and "Edited" or "Saved") .. " \7 " .. Words .. (Words == 1 and " word" or " words")
end
}))
Page:SetFocus(Editor)
Page:Invalidate()
end
function Page:OnLayout()
Build()
end
function Page:OnEvent(Name, A)
if (Name == "blockos_close") then
Save()
self:Stop()
return true
elseif (Name == "blockos_open" and A and fs.exists(A)) then
Open(A)
LoadNotes()
Build()
return true
end
return false
end
function Page:OnTerminate()
Save()
self:Stop()
end
-- Autosave a moment after typing stops.
Page:SetInterval(1, function()
if (Doc and Doc.Dirty and os.clock() - (Doc.LastEdit or 0) >= SaveDelay) then
Save()
return true
end
return false
end)
BlockOS.SetCloseHandler(true)
local Target = Args[1] and (shell and shell.resolve(Args[1]) or Args[1])
if (Target and not Target:find("^/")) then Target = "/" .. Target end
if (Target and fs.exists(Target) and not fs.isDir(Target)) then
Open(Target)
LoadNotes()
else
LoadNotes()
if (#Notes == 0) then
NewNote()
else
Open(Notes[1].Path)
end
end
Page:Run()
Save()
]]
Files["blockos/apps/print/app.cfg"] = [[
{ Name = "Print", Description = "Print text files on an attached printer.", Width = 44, Height = 16, Single = true, Icon = { Lines = { " \131 ", "\143\143\143" }, Fg = "gray", Bg = "white" } }
]]
Files["blockos/apps/print/main.lua"] = [[
-- Print: lays text files out on printed pages and sends them to an attached printer.
local Ui = BlockOS.Require("ui")
local Gfx = BlockOS.Require("gfx")
local Args = { ... }
-- Every CC printer page is 25 characters by 21 lines.
local PageW, PageH = 25, 21
local Page = Ui.Page.new()
local Doc = { Path = nil, Pages = {}, Title = "" }
local Copies = 1
local Shown = 1
local Scroll = 0
local PrinterName = nil
local Status = nil
local Busy = false
local TitleField
local Build
local function Printers()
local List = {}
for _, Name in ipairs(peripheral.getNames()) do
if (peripheral.getType(Name) == "printer") then
table.insert(List, { Name = Name, Device = peripheral.wrap(Name) })
end
end
return List
end
local function CurrentPrinter()
local List = Printers()
for _, Printer in ipairs(List) do
if (Printer.Name == PrinterName) then return Printer end
end
if (List[1]) then PrinterName = List[1].Name end
return List[1]
end
-- Wraps one line to the page width, breaking at spaces where possible and keeping indentation.
local function WrapLine(Line, Width)
local Rows = {}
Line = Line:gsub("\t", " ")
while (#Line > Width) do
local Space = Line:sub(1, Width + 1):match(".*() ")
if (Space and Space > 1) then
table.insert(Rows, Line:sub(1, Space - 1))
Line = Line:sub(Space + 1)
else
table.insert(Rows, Line:sub(1, Width))
Line = Line:sub(Width + 1)
end
end
table.insert(Rows, Line)
return Rows
end
local function Paginate(Text)
local Pages = {}
local Current = {}
Text = Text:gsub("\r\n?", "\n")
for Line in (Text .. "\n"):gmatch("(.-)\n") do
for _, Row in ipairs(WrapLine(Line, PageW)) do
if (#Current == PageH) then
table.insert(Pages, Current)
Current = {}
end
table.insert(Current, Row)
end
end
-- Drop trailing blank lines so a final newline does not cost a sheet of paper.
while (#Current > 0 and Current[#Current]:match("^%s*$")) do
table.remove(Current)
end
if (#Current > 0 or #Pages == 0) then table.insert(Pages, Current) end
return Pages
end
local function Load(Path)
local Handle = Path and fs.exists(Path) and not fs.isDir(Path) and fs.open(Path, "r")
if (not Handle) then
Status = Path and ("Cannot open " .. fs.getName(Path)) or nil
return false
end
local Text = Handle.readAll() or ""
Handle.close()
Doc.Path = Path
Doc.Pages = Paginate(Text)
Doc.Title = (fs.getName(Path):gsub("%.[^%.]+$", ""))
Shown, Scroll = 1, 0
Status = nil
BlockOS.SetTitle("Print \7 " .. fs.getName(Path))
return true
end
-- Two short lines: ink and paper levels, or why there are none.
local function PrinterStatus(Printer)
if (not Printer) then return "No printer", "attached" end
local Ok, Ink = pcall(Printer.Device.getInkLevel)
local _, Paper = pcall(Printer.Device.getPaperLevel)
if (not Ok) then return "Not responding", "" end
return "Ink " .. tostring(Ink), "Paper " .. tostring(Paper)
end
local function PrintDocument()
if (Busy or #Doc.Pages == 0) then return end
local Printer = CurrentPrinter()
if (not Printer) then
Status = "Attach a printer next to the computer or over a wired modem."
return
end
local Device = Printer.Device
local Title = (#TitleField.Value > 0) and TitleField.Value or Doc.Title
local Total = #Doc.Pages * Copies
local Done = 0
Busy = true
for _ = 1, Copies do
for Index, Lines in ipairs(Doc.Pages) do
if (not Device.newPage()) then
if (Device.getPaperLevel() == 0) then
Status = "Out of paper. Add paper to the printer and try again."
elseif (Device.getInkLevel() == 0) then
Status = "Out of ink. Add a dye to the printer and try again."
else
Status = "The output tray is full. Take the printed pages out."
end
Busy = false
return
end
Device.setPageTitle((#Doc.Pages > 1) and (Title .. " " .. Index .. "/" .. #Doc.Pages) or Title)
for Row, Line in ipairs(Lines) do
Device.setCursorPos(1, Row)
Device.write(Line)
end
if (not Device.endPage()) then
Status = "The output tray is full. Take the printed pages out."
Busy = false
return
end
Done = Done + 1
Status = "Printing page " .. Done .. " of " .. Total .. "..."
Page:Draw()
end
end
Busy = false
Status = "Printed " .. Total .. ((Total == 1) and " page." or " pages.")
BlockOS.Notify("Print", Title .. " \7 " .. Total .. ((Total == 1) and " page" or " pages"), { Sound = false })
end
local function ChooseFile()
local Path = Ui.PickFile({ Title = "Print", Dir = Doc.Path and fs.getDir(Doc.Path) or "/home" })
if (Path and Load(Path)) then
Build()
elseif (Path) then
Build()
end
end
local function ChoosePrinter(X, Y)
local List = Printers()
if (#List == 0) then return end
local Options = {}
for _, Printer in ipairs(List) do
table.insert(Options, { Text = Printer.Name })
end
local Index = Ui.ContextMenu(X, Y, Options)
if (Index) then PrinterName = List[Index].Name end
end
local function DrawPreview(Self, T, Th)
Gfx.Fill(T, Self.X, Self.Y, Self.W, Self.H, Th.Panel)
local Lines = Doc.Pages[Shown]
if (not Lines) then
local Message = Gfx.Wrap("Choose a text file to print, or right-click one in Finder and pick Print.", Self.W - 4)
for Row, Line in ipairs(Message) do
Gfx.Write(T, Self.X + 2, Self.Y + Row, Line, Th.SubText, Th.Panel)
end
return
end
local PaperX = Self.X + math.max(0, math.floor((Self.W - PageW) / 2))
for Row = 1, math.min(Self.H, PageH - Scroll) do
local Line = Lines[Row + Scroll] or ""
Gfx.Write(T, PaperX, Self.Y + Row - 1, Gfx.Pad(Line, PageW), colors.black, colors.white)
end
end
function Build()
Page:Clear()
local W, H = Page.W, Page.H
local SideW = 16
local PreviewW = math.max(PageW + 2, W - SideW - 1)
local SideX = PreviewW + 2
local Narrow = SideX + SideW - 1 > W
Page:Add(Ui.Panel.new({ X = 1, Y = 1, W = W, H = 1, Bg = "Panel" }))
Page:Add(Ui.Label.new({ X = 2, Y = 1, W = W - 12, Bg = "Panel", Text = function()
return Doc.Path and fs.getName(Doc.Path) or "No document"
end }))
Page:Add(Ui.Button.new({ X = W - 8, Y = 1, W = 9, Text = "Open...", Bg = "Panel", OnClick = ChooseFile }))
Page:Add(Ui.Canvas.new({
X = 1, Y = 2, W = Narrow and W or PreviewW, H = H - 1,
OnDraw = DrawPreview,
OnEvent = function(_, Name, A)
if (Name == "mouse_scroll") then
Scroll = math.max(0, math.min(PageH - (H - 1), Scroll + A * 2))
return true
end
return false
end
}))
if (Narrow) then
Page:Add(Ui.Button.new({ X = W - 8, Y = H, W = 9, Text = "Print", Style = "primary", OnClick = PrintDocument }))
return
end
Page:Add(Ui.Label.new({ X = SideX, Y = 2, Text = "Printer", Fg = "SubText" }))
local PickPrinter
PickPrinter = Page:Add(Ui.Button.new({ X = SideX, Y = 3, W = SideW, Text = function()
local Printer = CurrentPrinter()
return Gfx.Truncate(Printer and Printer.Name or "None", SideW - 3) .. " \31"
end, OnClick = function() ChoosePrinter(PickPrinter.X, PickPrinter.Y + 1) end }))
Page:Add(Ui.Label.new({ X = SideX, Y = 4, W = SideW, Fg = "SubText", Text = function()
return (PrinterStatus(CurrentPrinter()))
end }))
Page:Add(Ui.Label.new({ X = SideX, Y = 5, W = SideW, Fg = "SubText", Text = function()
return select(2, PrinterStatus(CurrentPrinter()))
end }))
Page:Add(Ui.Label.new({ X = SideX, Y = 6, Text = "Title", Fg = "SubText" }))
TitleField = Page:Add(Ui.Input.new({ X = SideX, Y = 7, W = SideW, Value = Doc.Title, Placeholder = "Page title", MaxLength = 30 }))
Page:Add(Ui.Label.new({ X = SideX, Y = 9, Text = "Copies", Fg = "SubText" }))
Page:Add(Ui.Button.new({ X = SideX + 8, Y = 9, W = 3, Text = "-", OnClick = function() Copies = math.max(1, Copies - 1) end }))
Page:Add(Ui.Label.new({ X = SideX + 11, Y = 9, W = 3, Align = "center", Text = function() return tostring(Copies) end }))
Page:Add(Ui.Button.new({ X = SideX + 14, Y = 9, W = 2, Text = "+", OnClick = function() Copies = math.min(64, Copies + 1) end }))
Page:Add(Ui.Button.new({ X = SideX, Y = 11, W = 3, Text = "\17", OnClick = function()
Shown = math.max(1, Shown - 1)
Scroll = 0
end }))
Page:Add(Ui.Label.new({ X = SideX + 3, Y = 11, W = SideW - 6, Align = "center", Text = function()
return "Page " .. math.min(Shown, #Doc.Pages) .. "/" .. #Doc.Pages
end }))
Page:Add(Ui.Button.new({ X = SideX + SideW - 3, Y = 11, W = 3, Text = "\16", OnClick = function()
Shown = math.min(#Doc.Pages, Shown + 1)
Scroll = 0
end }))
Page:Add(Ui.Button.new({ X = SideX, Y = 13, W = SideW, Text = "Print", Style = "primary", OnClick = PrintDocument }))
Page:Add(Ui.Label.new({ X = SideX, Y = 14, W = SideW, H = math.max(1, H - 13), Fg = "SubText", Text = function()
return Status or ""
end }))
Page:Invalidate()
end
function Page:OnLayout()
Build()
end
function Page:OnEvent(Name, A)
if (Name == "peripheral" or Name == "peripheral_detach") then
return true
elseif (Name == "blockos_open" and A) then
if (Load(A)) then Build() end
return true
end
return false
end
Load(Args[1])
Page:Run()
]]
Files["blockos/apps/recorder/app.cfg"] = [[
{ Name = "Recorder", Description = "Record the screen and play recordings back.", Width = 40, Height = 13, Single = true, Icon = { Lines = { " \7 ", "REC" }, Fg = "red", Bg = "black" } }
]]
Files["blockos/apps/recorder/main.lua"] = [=[
-- Recorder: captures the BlockOS screen into .rec files, plays them back,
-- and can export a recording as a standalone player program.
local Ui = BlockOS.Require("ui")
local Gfx = BlockOS.Require("gfx")
local Config = BlockOS.Require("config")
local Args = { ... }
local Folder = "/home/Recordings"
local MaxFrames = 2000
local Page = Ui.Page.new()
local Recording = nil
local Options = { Hide = true, Countdown = true, Fps = 8 }
local Build
local function Serialise(Value)
local Ok, Text = pcall(textutils.serialise, Value, { compact = true })
if (Ok) then return Text end
return textutils.serialise(Value)
end
local function ListRecordings()
if (not fs.exists(Folder)) then fs.makeDir(Folder) end
local Items = {}
for _, Name in ipairs(fs.list(Folder)) do
if (Name:match("%.rec$")) then
local Path = fs.combine(Folder, Name)
table.insert(Items, { Text = Name:gsub("%.rec$", ""), Right = Gfx.FormatSize(fs.getSize(Path)), Icon = "\7", IconFg = colors.red, Path = "/" .. Path })
end
end
table.sort(Items, function(A, B) return A.Text > B.Text end)
return Items
end
local function Capture()
local Frame = BlockOS.Capture()
local Now = os.epoch("utc")
local Changed = {}
local Any = false
for Y, Line in ipairs(Frame.Lines) do
local Previous = Recording.Last[Y]
if (not Previous or Previous[1] ~= Line[1] or Previous[2] ~= Line[2] or Previous[3] ~= Line[3]) then
Changed[Y] = Line
Recording.Last[Y] = Line
Any = true
end
end
if (Any) then
table.insert(Recording.Frames, { T = Now - Recording.Started, Lines = Changed })
end
Recording.W, Recording.H = Frame.W, Frame.H
end
local function StopRecording()
if (not Recording) then return end
Page:ClearInterval(Recording.Interval)
BlockOS.SetRecording(false)
local Data = {
Version = 1,
W = Recording.W,
H = Recording.H,
Duration = os.epoch("utc") - Recording.Started,
Frames = Recording.Frames
}
Recording = nil
if (#Data.Frames == 0) then return end
local Path = fs.combine(Folder, "Recording " .. os.date("%Y-%m-%d %H%M%S") .. ".rec")
Config.WriteText(Path, Serialise(Data))
BlockOS.Notify("Recorder", "Saved " .. fs.getName(Path), { AppID = "recorder", Sound = false })
Build()
end
local function StartRecording()
if (Recording) then return end
if (Options.Countdown) then
for Count = 3, 1, -1 do
BlockOS.Notify("Recorder", "Recording starts in " .. Count .. "...", { Sound = false, Duration = 1 })
sleep(1)
end
end
Recording = { Frames = {}, Last = {}, Started = os.epoch("utc") }
BlockOS.SetRecording(true)
if (Options.Hide) then BlockOS.Minimize() end
Capture()
Recording.Interval = Page:SetInterval(1 / Options.Fps, function()
if (not Recording) then return false end
Capture()
if (#Recording.Frames >= MaxFrames) then StopRecording() end
return true
end)
Build()
end
local function LoadRecording(Path)
local Data = textutils.unserialise(Config.ReadText(Path) or "")
if (type(Data) ~= "table" or type(Data.Frames) ~= "table") then return nil end
return Data
end
-- Plays a recording full screen; any click or key other than space stops it.
local function Play(Path)
local Data = LoadRecording(Path)
if (not Data) then
Ui.Alert("Recorder", "This file is not a valid recording.")
return
end
BlockOS.SetFullscreen(true)
sleep(0)
local T = term.current()
local W, H = T.getSize()
local Screen = window.create(T, 1, 1, W, H, true)
Screen.setBackgroundColor(colors.black)
Screen.clear()
local Paused = false
local Index = 1
local Clock = 0
local Last = os.epoch("utc")
local Timer = os.startTimer(0)
while (Index <= #Data.Frames) do
local Event = table.pack(os.pullEventRaw())
local Name = Event[1]
if (Name == "terminate" or Name == "mouse_click" or (Name == "key" and Event[2] ~= keys.space)) then
break
elseif (Name == "key" and Event[2] == keys.space) then
Paused = not Paused
Last = os.epoch("utc")
elseif (Name == "timer" and Event[2] == Timer) then
local Now = os.epoch("utc")
if (not Paused) then Clock = Clock + (Now - Last) end
Last = Now
while (Index <= #Data.Frames and Data.Frames[Index].T <= Clock) do
for Y, Line in pairs(Data.Frames[Index].Lines) do
if (Y <= H) then
Screen.setCursorPos(1, Y)
Screen.blit(Line[1]:sub(1, W), Line[2]:sub(1, W), Line[3]:sub(1, W))
end
end
Index = Index + 1
end
Timer = os.startTimer(.05)
end
end
sleep(.5)
Screen.setVisible(false)
BlockOS.SetFullscreen(false)
sleep(0)
Page:Resize()
Page:Draw()
end
local PlayerTemplate = [[
-- BlockOS recording player (exported by Recorder)
local Data = textutils.unserialise(%q)
local W, H = term.getSize()
term.setBackgroundColor(colors.black)
term.clear()
local Start = os.epoch("utc")
for _, Frame in ipairs(Data.Frames) do
local Wait = Frame.T - (os.epoch("utc") - Start)
if (Wait > 0) then sleep(Wait / 1000) end
for Y, Line in pairs(Frame.Lines) do
if (Y <= H) then
term.setCursorPos(1, Y)
term.blit(Line[1]:sub(1, W), Line[2]:sub(1, W), Line[3]:sub(1, W))
end
end
end
term.setCursorPos(1, H)
]]
local function Export(Item)
local Data = Config.ReadText(Item.Path)
if (not Data) then return end
local Target = Item.Path:gsub("%.rec$", "") .. " player.lua"
Config.WriteText(Target, string.format(PlayerTemplate, Data))
Ui.Alert("Export", "Saved a standalone player to " .. Target .. ". Run it on any advanced computer.")
Build()
end
function Build()
Page:Clear()
local W, H = Page.W, Page.H
Page:Add(Ui.Canvas.new({
X = 1, Y = 1, W = W, H = 4,
OnDraw = function(Self, T, Th)
Gfx.Fill(T, Self.X, Self.Y, Self.W, Self.H, Th.Panel)
local Color = Recording and colors.red or colors.gray
Gfx.Fill(T, Self.X + 2, Self.Y + 1, 4, 2, Recording and colors.red or Th.WindowBg)
Gfx.Write(T, Self.X + 3, Self.Y + 1, "\7\7", Recording and colors.white or colors.red, Recording and colors.red or Th.WindowBg)
if (Recording) then
local Seconds = math.floor((os.epoch("utc") - Recording.Started) / 1000)
Gfx.Write(T, Self.X + 8, Self.Y + 1, string.format("Recording %d:%02d", math.floor(Seconds / 60), Seconds % 60), Color, Th.Panel)
Gfx.Write(T, Self.X + 8, Self.Y + 2, #Recording.Frames .. " frames \7 click to stop", Th.SubText, Th.Panel)
else
Gfx.Write(T, Self.X + 8, Self.Y + 1, "Record Screen", Th.PanelFg, Th.Panel)
Gfx.Write(T, Self.X + 8, Self.Y + 2, Gfx.Truncate("Stop from the REC item in the menu bar", Self.W - 9), Th.SubText, Th.Panel)
end
end,
OnEvent = function(_, Name)
if (Name ~= "mouse_click") then return false end
if (Recording) then StopRecording() else StartRecording() end
return true
end
}))
Page:Add(Ui.Checkbox.new({ X = 2, Y = 6, Text = "Hide window", Value = Options.Hide, OnChange = function(_, Value) Options.Hide = Value end }))
Page:Add(Ui.Checkbox.new({ X = 18, Y = 6, Text = "Countdown", Value = Options.Countdown, OnChange = function(_, Value) Options.Countdown = Value end }))
local List = Page:Add(Ui.List.new({ X = 1, Y = 8, W = W, H = H - 8 }))
List:SetItems(ListRecordings())
List.OnActivate = function(_, _, Item) Play(Item.Path) end
List.OnContext = function(_, _, Item, X, Y)
if (not Item) then return end
local Choices = { "Play", "Export Player", "Rename", "Show in Finder", "-", "Delete" }
local Choice = Choices[Ui.ContextMenu(X, Y, Choices) or 0]
if (Choice == "Play") then
Play(Item.Path)
elseif (Choice == "Export Player") then
Export(Item)
elseif (Choice == "Rename") then
local Name = Ui.Prompt("Rename", "Name:", Item.Text)
if (Name and #Name > 0) then
local Target = fs.combine(Folder, Name .. ".rec")
if (not fs.exists(Target)) then fs.move(Item.Path, Target) end
Build()
end
elseif (Choice == "Show in Finder") then
BlockOS.Launch("finder", { Folder })
elseif (Choice == "Delete") then
if (Ui.Confirm("Delete", "Delete \"" .. Item.Text .. "\"?", "Delete")) then
fs.delete(Item.Path)
Build()
end
end
end
if (#List.Items == 0) then
Page:Add(Ui.Label.new({ X = 2, Y = 9, W = W - 2, Text = "No recordings yet.", Fg = "SubText" }))
end
Page:Add(Ui.Label.new({ X = 1, Y = H, W = W, Bg = "Panel", Fg = "SubText", Text = " Double-click to play \7 space pauses" }))
Page:Invalidate()
end
function Page:OnLayout()
Build()
end
function Page:OnEvent(Name, A)
if (Name == "blockos_recorder_stop") then
StopRecording()
return true
elseif (Name == "blockos_open" and A) then
Play(A)
return true
end
return false
end
function Page:OnTerminate()
StopRecording()
self:Stop()
end
Page:SetInterval(1, function() return Recording ~= nil end)
if (Args[1] and fs.exists(Args[1])) then
Play(Args[1])
end
Page:Run()
StopRecording()
]=]
Files["blockos/apps/reminders/app.cfg"] = [[
{ Name = "Reminders", Description = "Lists and reminders with due-time notifications.", Width = 44, Height = 14, Single = true, Icon = { Lines = { "\7\140\140", "\7\140\140" }, Fg = "orange", Bg = "white" } }
]]
Files["blockos/apps/reminders/main.lua"] = [[
-- Reminders: smart lists, custom lists, due times and priorities.
-- Quick entry: "Buy bread !! @ tomorrow 9:00" sets priority 2 and a due time.
local Ui = BlockOS.Require("ui")
local Gfx = BlockOS.Require("gfx")
local Schedule = BlockOS.Require("schedule")
local Page = Ui.Page.new()
local Data = Schedule.Load()
local Current = { Kind = "smart", ID = "today" }
local Build
local Smart = {
{ ID = "today", Name = "Today", Icon = "\15", Color = colors.blue },
{ ID = "scheduled", Name = "Scheduled", Icon = "\4", Color = colors.red },
{ ID = "all", Name = "All", Icon = "\7", Color = colors.gray },
{ ID = "done", Name = "Completed", Icon = "x", Color = colors.green }
}
local function EndOfToday()
local Now = Schedule.Now()
local Today = os.date("!*t", Now)
return Now - (Today.hour * 3600 + Today.min * 60 + Today.sec) + 86399
end
local function Matches(Reminder)
if (Current.Kind == "list") then
return Reminder.List == Current.ID and not Reminder.Done
end
if (Current.ID == "done") then return Reminder.Done end
if (Reminder.Done) then return false end
if (Current.ID == "today") then
return Reminder.Due ~= nil and Reminder.Due <= EndOfToday()
elseif (Current.ID == "scheduled") then
return Reminder.Due ~= nil
end
return true
end
local function Visible()
local Result = {}
for _, Reminder in ipairs(Data.Reminders) do
if (Matches(Reminder)) then
table.insert(Result, Reminder)
end
end
table.sort(Result, function(A, B)
if ((A.Due ~= nil) ~= (B.Due ~= nil)) then return A.Due ~= nil end
if (A.Due and B.Due and A.Due ~= B.Due) then return A.Due < B.Due end
if ((A.Priority or 0) ~= (B.Priority or 0)) then return (A.Priority or 0) > (B.Priority or 0) end
return A.ID < B.ID
end)
return Result
end
local function CountFor(Kind, ID)
local Saved = Current
Current = { Kind = Kind, ID = ID }
local Count = #Visible()
Current = Saved
return Count
end
local function CurrentName()
if (Current.Kind == "list") then return Current.ID end
for _, Entry in ipairs(Smart) do
if (Entry.ID == Current.ID) then return Entry.Name end
end
return ""
end
local function QuickAdd(Text)
Text = Text:gsub("^%s+", ""):gsub("%s+$", "")
if (#Text == 0) then return end
local Due
local Title, DueText = Text:match("^(.-)%s*@%s*(.+)$")
if (Title) then
Due = Schedule.ParseDue(DueText)
if (not Due) then
Ui.Alert("Reminders", "Could not understand \"" .. DueText .. "\". Try \"in 10m\", \"14:30\", \"tomorrow 9:00\" or \"2026-11-02 08:00\".")
return
end
Text = Title
end
local Bangs = Text:match("(!+)%s*$")
local Priority = 0
if (Bangs) then
Priority = math.min(3, #Bangs)
Text = Text:gsub("%s*!+%s*$", "")
end
if (not Due and Current.Kind == "smart" and Current.ID == "today") then
Due = EndOfToday() - 59
end
Schedule.AddReminder({
Title = Text,
Due = Due,
Priority = Priority,
List = (Current.Kind == "list") and Current.ID or (Data.Lists[1] or "Personal")
})
end
local function EditMenu(Reminder, X, Y)
local Options = { Reminder.Done and "Mark Incomplete" or "Mark Complete", "Rename...", "Set Due...", "Clear Due", "Priority...", "Move to List...", "Notes...", "-", "Delete" }
local Choice = Options[Ui.ContextMenu(X, Y, Options) or 0]
if (Choice == "Mark Complete" or Choice == "Mark Incomplete") then
Reminder.Done = not Reminder.Done
elseif (Choice == "Rename...") then
Reminder.Title = Ui.Prompt("Rename", "Title:", Reminder.Title) or Reminder.Title
elseif (Choice == "Set Due...") then
local Text = Ui.Prompt("Due", "When? (in 10m, 14:30, tomorrow 9:00)", "")
local Due = Text and Schedule.ParseDue(Text)
if (Due) then
Reminder.Due = Due
Reminder.Notified = false
elseif (Text) then
Ui.Alert("Due", "That time was not understood.")
end
elseif (Choice == "Clear Due") then
Reminder.Due = nil
elseif (Choice == "Priority...") then
local Index = Ui.Choose("Priority", { "None", "Low (!)", "Medium (!!)", "High (!!!)" }, (Reminder.Priority or 0) + 1)
if (Index) then Reminder.Priority = Index - 1 end
elseif (Choice == "Move to List...") then
local Index = Ui.Choose("Move to", Data.Lists)
if (Index) then Reminder.List = Data.Lists[Index] end
elseif (Choice == "Notes...") then
Reminder.Notes = Ui.Prompt("Notes", Reminder.Title, Reminder.Notes or "") or Reminder.Notes
elseif (Choice == "Delete") then
Schedule.RemoveReminder(Reminder.ID)
Build()
return
end
Schedule.Save()
Build()
end
function Build()
Page:Clear()
local W, H = Page.W, Page.H
local SideW = (W >= 36) and 14 or 0
local Sidebar = Page:Add(Ui.List.new({ X = 1, Y = 1, W = SideW, H = H - 1, Bg = "Panel", SingleClick = true, Focusable = false, Visible = SideW > 0 }))
local Entries = {}
local Selected = 0
for _, Entry in ipairs(Smart) do
table.insert(Entries, { Text = Entry.Name, Icon = Entry.Icon, IconFg = Entry.Color, Right = tostring(CountFor("smart", Entry.ID)), Kind = "smart", ID = Entry.ID })
if (Current.Kind == "smart" and Current.ID == Entry.ID) then Selected = #Entries end
end
table.insert(Entries, { Text = "My Lists", Header = true })
for _, Name in ipairs(Data.Lists) do
table.insert(Entries, { Text = Name, Icon = "\7", IconFg = colors.orange, Right = tostring(CountFor("list", Name)), Kind = "list", ID = Name })
if (Current.Kind == "list" and Current.ID == Name) then Selected = #Entries end
end
Sidebar:SetItems(Entries)
Sidebar.Selected = Selected
Sidebar.OnActivate = function(_, _, Item)
if (Item.Header) then return end
Current = { Kind = Item.Kind, ID = Item.ID }
Build()
end
Sidebar.OnContext = function(_, _, Item, X, Y)
local Options = { "New List..." }
if (Item and Item.Kind == "list") then
table.insert(Options, "Rename List...")
table.insert(Options, "Delete List")
end
local Choice = Options[Ui.ContextMenu(X, Y, Options) or 0]
if (Choice == "New List...") then
local Name = Ui.Prompt("New List", "Name:", "")
if (Name and #Name > 0) then
table.insert(Data.Lists, Name)
Schedule.Save()
Current = { Kind = "list", ID = Name }
end
elseif (Choice == "Rename List...") then
local Name = Ui.Prompt("Rename List", "Name:", Item.ID)
if (Name and #Name > 0) then
for Index, Existing in ipairs(Data.Lists) do
if (Existing == Item.ID) then Data.Lists[Index] = Name end
end
for _, Reminder in ipairs(Data.Reminders) do
if (Reminder.List == Item.ID) then Reminder.List = Name end
end
Current = { Kind = "list", ID = Name }
Schedule.Save()
end
elseif (Choice == "Delete List") then
if (Ui.Confirm("Delete List", "Delete \"" .. Item.ID .. "\" and its reminders?", "Delete")) then
for Index = #Data.Lists, 1, -1 do
if (Data.Lists[Index] == Item.ID) then table.remove(Data.Lists, Index) end
end
for Index = #Data.Reminders, 1, -1 do
if (Data.Reminders[Index].List == Item.ID) then table.remove(Data.Reminders, Index) end
end
Current = { Kind = "smart", ID = "all" }
Schedule.Save()
end
end
Build()
end
Page:Add(Ui.Button.new({ X = 1, Y = H, W = math.max(SideW, 1), Text = "+ List", Bg = "Panel", Visible = SideW > 0, OnClick = function()
local Name = Ui.Prompt("New List", "Name:", "")
if (Name and #Name > 0) then
table.insert(Data.Lists, Name)
Schedule.Save()
Current = { Kind = "list", ID = Name }
Build()
end
end }))
local MainX = SideW + 2
local MainW = W - SideW - 2
local Items = Visible()
Page:Add(Ui.Label.new({ X = MainX, Y = 1, W = MainW - 4, Text = CurrentName(), Fg = "Accent" }))
Page:Add(Ui.Label.new({ X = MainX + MainW - 4, Y = 1, W = 4, Text = tostring(#Items), Fg = "SubText", Align = "right" }))
local List = Page:Add(Ui.List.new({ X = MainX, Y = 3, W = MainW, H = H - 4 }))
local Rows = {}
for _, Reminder in ipairs(Items) do
table.insert(Rows, { Reminder = Reminder })
end
List:SetItems(Rows)
List.RenderItem = function(_, T, Th, Item, X, Y, Width, IsSelected, RowBg, RowFg)
local Reminder = Item.Reminder
local Box = Reminder.Done and "\7" or "o"
Gfx.Write(T, X, Y, Box, Reminder.Done and Th.Accent or Th.SubText, RowBg)
local Bang = string.rep("!", Reminder.Priority or 0)
local Due = Reminder.Due and Schedule.FormatDue(Reminder.Due) or ""
local Overdue = Reminder.Due and not Reminder.Done and Reminder.Due < Schedule.Now()
local TitleW = Width - 2 - #Due - 1
local Title = (#Bang > 0 and (Bang .. " ") or "") .. Reminder.Title .. (Reminder.Notes and " \26" or "")
Gfx.Write(T, X + 2, Y, Gfx.Truncate(Title, TitleW), IsSelected and RowFg or (Reminder.Done and Th.SubText or Th.Text), RowBg)
if (#Due > 0) then
Gfx.Write(T, X + Width - #Due, Y, Due, IsSelected and RowFg or (Overdue and colors.red or Th.SubText), RowBg)
end
end
List.OnActivate = function(_, _, Item)
Item.Reminder.Done = not Item.Reminder.Done
Schedule.Save()
Build()
end
List.OnContext = function(_, _, Item, X, Y)
if (Item) then EditMenu(Item.Reminder, X, Y) end
end
if (#Rows == 0) then
Page:Add(Ui.Label.new({ X = MainX, Y = 4, W = MainW, H = 2, Text = "No reminders. Type below to add one.", Fg = "SubText" }))
end
local Entry = Page:Add(Ui.Input.new({ X = MainX, Y = H, W = MainW, Placeholder = "+ New reminder (!! priority, @ in 10m)" }))
Entry.OnSubmit = function(Self, Value)
QuickAdd(Value)
Self:SetValue("")
Build()
end
Page:SetFocus(Entry)
Page:Invalidate()
end
function Page:OnLayout()
Build()
end
function Page:OnEvent(Name)
if (Name == "blockos_schedule") then
if (not (self.Focus and self.Focus.Value and #self.Focus.Value > 0)) then
Build()
end
return true
end
return false
end
Page:Run()
]]
Files["blockos/apps/savannah/app.cfg"] = [[
{ Name = "Savannah", Description = "Browse ccinternet websites with tabs, favourites, history and find on page.", Width = 50, Height = 16, Single = true, Icon = { Lines = { " \15 ", "~~~" }, Fg = "yellow", Bg = "orange" } }
]]
Files["blockos/apps/savannah/main.lua"] = [=[
-- Savannah: the BlockOS web browser for ccinternet (https://github.com/tamayaren/ccinternet).
-- Tabs, favourites, history, find on page, local site previews (local://) and the built-in
-- savannah:// pages. Network requests run as background tasks so the window stays responsive.
-- Args: an address (ccmd://site.com, domain://) or a path to a local .ccmd page.
local Ui = BlockOS.Require("ui")
local Gfx = BlockOS.Require("gfx")
local Config = BlockOS.Require("config")
local Theme = BlockOS.Require("theme")
local Ccmd = BlockOS.Require("ccinternet/ccmd")
local Layout = BlockOS.Require("ccinternet/layout")
local Image = BlockOS.Require("ccinternet/image")
local Protocol = BlockOS.Require("ccinternet/protocol")
local Site = BlockOS.Require("ccinternet/site")
local Tasks = BlockOS.Load("/blockos/lib/tasks.lua")
local Args = { ... }
local Page = Ui.Page.new()
local C = {
DataPath = "/blockos/data/savannah.dat",
StartPage = "savannah://start",
MaxHistory = 150,
MaxTabHistory = 30,
CachedPages = 8,
MaxRedirects = 5,
MaxUpload = 125000,
MaxImages = 64,
MaxField = 256,
MaxMatches = 200,
MessageTime = 3
}
-- Everything the window shows lives here (CC limits how many locals a file may have).
local S = {
Tabs = {},
Current = 1,
Data = nil,
Transport = nil,
Sites = {}, -- local site key -> Site object
Images = {}, -- absolute image URL -> picture | false
ImageCount = 0,
Find = nil, -- { Query, Matches, Index } while the find bar is open
Message = nil,
MessageAt = 0,
W = {} -- widgets
}
local Nav = {}
local Internal = {}
local View = {}
--
-- Saved data: settings, favourites, history and known local sites.
--
local function DefaultHost()
local Host = settings and settings.get("ccinternet.host")
if (type(Host) == "string" and Host ~= "") then return Host end
return Protocol.DefaultHost
end
local function LoadData()
local Data = Config.Read(C.DataPath)
if (type(Data) ~= "table") then Data = {} end
if (type(Data.Host) ~= "string" or Data.Host == "") then Data.Host = DefaultHost() end
if (type(Data.Home) ~= "string" or Data.Home == "") then Data.Home = C.StartPage end
if (type(Data.Favourites) ~= "table") then
Data.Favourites = {
{ Title = "Domains", Url = "domain://" },
{ Title = "Directory", Url = "domain://directory" }
}
end
if (type(Data.History) ~= "table") then Data.History = {} end
if (type(Data.Sites) ~= "table") then Data.Sites = {} end
S.Data = Data
end
local function SaveData()
Config.Write(C.DataPath, S.Data)
end
local function Say(Text)
S.Message = Text
S.MessageAt = os.clock()
Page:Invalidate()
end
local function SafeUrl(Url)
return (tostring(Url):gsub("[%(%)%s]", function(Char) return string.format("%%%02X", Char:byte()) end))
end
local function LinkSource(Text, Url)
return "[" .. Ccmd.Escape((tostring(Text):gsub("[%[%]\n]", ""))) .. "](" .. SafeUrl(Url) .. ")"
end
--
-- Tabs
--
function Nav.Tab()
return S.Tabs[S.Current]
end
function Nav.NewTabState()
return { History = {}, Index = 0, Scroll = 0, State = {}, Title = "New Tab" }
end
function Nav.FavouriteIndex(Url)
for Index, Entry in ipairs(S.Data.Favourites) do
if (Entry.Url == Url) then return Index end
end
return nil
end
--
-- Requests
--
function Nav.LocalSite(Key)
local Root = S.Data.Sites[Key]
if (not Root) then return nil end
if (not S.Sites[Key] or S.Sites[Key].Root ~= "/" .. fs.combine(Root, "")) then
S.Sites[Key] = Site.New(Root)
end
return S.Sites[Key]
end
-- Picks a short name for a local folder, reusing the one it already has.
function Nav.SiteKey(Root)
Root = "/" .. fs.combine(Root, "")
for Key, Known in pairs(S.Data.Sites) do
if ("/" .. fs.combine(Known, "") == Root) then return Key end
end
local Base = Root == "/" and "computer" or fs.getName(Root):lower():gsub("[^%w%-]+", "-"):gsub("^%-+", ""):gsub("%-+$", "")
if (Base == "") then Base = "site" end
local Key, Number = Base, 1
while (S.Data.Sites[Key]) do
Number = Number + 1
Key = Base .. "-" .. Number
end
S.Data.Sites[Key] = Root
SaveData()
return Key
end
local function Response(Status, Source, Headers)
Headers = Headers or {}
Headers["Content-Type"] = Headers["Content-Type"] or "text/ccmd"
return { Status = Status, Headers = Headers, Body = Source }
end
-- Sends one request (no redirects). May block, so it runs inside a task.
function Nav.Request(Parsed, Method, Body, Options)
if (Parsed.Scheme == "savannah") then
return Internal.Handle(Method, Parsed, Body)
elseif (Parsed.Scheme == "local") then
local Server = Nav.LocalSite(Parsed.Host)
if (not Server or not fs.isDir(Server.Root)) then
return Response(404, Site.Message("Folder not found", "Savannah no longer knows where the local site \"" .. Parsed.Host .. "\" is. Open one of its pages again from Finder or File > Open File.", "red"))
end
if (Options and Options.Refresh) then Server:Refresh() end
return Server:Request(Method, Parsed, Body, "local://" .. Parsed.Host)
elseif (Parsed.Scheme == "ccmd" or Parsed.Scheme == "domain") then
return S.Transport.Send(Method, Protocol.FormatUrl(Parsed), { Accept = Options and Options.Accept or "text/ccmd" }, Body, Options)
end
return nil, Parsed.Scheme .. ":// addresses are not supported."
end
-- Requests a page and follows redirects. Returns the response, the final parsed URL and a problem.
function Nav.Fetch(Tab, Parsed, Method, Body)
local Result, Problem
for _ = 1, C.MaxRedirects + 1 do
Result, Problem = Nav.Request(Parsed, Method, Body, {
Refresh = true,
OnProgress = function(Received, Total)
if (Tab.Loading) then
Tab.Loading.Received, Tab.Loading.Total = Received, Total
Page:Invalidate()
end
end
})
if (not Result) then break end
local Location = Result.Headers and Result.Headers.Location
if (Result.Status < 300 or Result.Status >= 400 or type(Location) ~= "string") then break end
local Next = Protocol.ParseUrl(Location, Parsed)
if (not Next) then break end
Parsed, Method, Body = Next, "GET", nil
end
return Result, Parsed, Problem
end
local function TextDocument(Title, Text)
local Lines = {}
for Line in (tostring(Text):gsub("\r", "") .. "\n"):gmatch("(.-)\n") do
Lines[#Lines + 1] = { { Text = (Line:gsub("\t", " "):gsub("[%z\1-\8\11-\31]", "?")) } }
end
local Th = Theme.Get()
return {
Title = Title, Location = "/", Meta = {}, Inputs = {}, Forms = {}, Errors = {},
Blocks = { { Type = "text", Align = "left", Lines = Lines } },
Background = Gfx.ToBlit(Th.WindowBg), TextColor = Gfx.ToBlit(Th.Text), LinkColor = "3"
}
end
function Nav.ConnectError(Url, Problem)
Problem = tostring(Problem or "Unknown error")
local Text
if (Problem:find("No modem")) then
Text = "Savannah can't open the page because this computer isn't connected to the internet.\n\nAttach a wireless or ender modem and try again."
elseif (Problem:find("Could not find")) then
Text = "Savannah can't find the ccinternet core named \"" .. S.Data.Host .. "\".\n\nCheck that the core is running and in range, or pick another server in Settings."
elseif (Problem:find("did not answer")) then
Text = "The server stopped answering.\n\nIt may be busy or out of range. Try again in a moment."
else
Text = Problem
end
return Site.Message("Can't open the page", Text, "red", { { "Try again", Url }, { "Settings", "savannah://settings" } })
end
-- Turns a response into a document.
function Nav.Interpret(Result, Parsed)
local Type = (Result.Headers["Content-Type"] or ""):lower()
local Name = Parsed.Path:match("[^/]*$")
if (Name == "") then Name = Parsed.Host end
if (type(Result.Body) ~= "string") then
return Ccmd.Parse(Site.Message("Empty response", "The server sent nothing back (status " .. Result.Status .. ")."))
end
if (Type == "text/ccmd" or (Type == "" and Parsed.Path:lower():match("%.ccmd$"))) then
return Ccmd.Parse(Result.Body)
end
local Kind = Type:match("^image/(%w+)")
if (Kind and Image.Extensions[Kind]) then
local Key = Protocol.FormatUrl(Parsed)
S.Images[Key] = Image.Parse(Result.Body, Kind) or false
local Doc = TextDocument(Name, "")
Doc.Blocks = { { Type = "break" }, { Type = "image", Src = Key, Align = "center", Alt = Name } }
return Doc
end
if (Type:match("^text/")) then return TextDocument(Result.Headers.Title or Name, Result.Body) end
local Doc = Ccmd.Parse(Site.Message(Name, "This is a " .. Gfx.FormatSize(#Result.Body) .. " file (" .. (Type ~= "" and Type or "unknown type") .. ").", "yellow"))
table.insert(Doc.Blocks, { Type = "break" })
table.insert(Doc.Blocks, { Type = "button", Text = "Download", Href = "savannah://save", Align = "center", Color = "b", TextColor = "0" })
return Doc
end
--
-- Showing pages
--
local function CollectImages(Blocks, Into)
for _, Block in ipairs(Blocks) do
if (Block.Type == "image") then Into[#Into + 1] = Block.Src end
if (Block.Type == "form") then CollectImages(Block.Blocks, Into) end
end
return Into
end
function Nav.ImageKey(Tab, Src)
local Parsed = Tab.Parsed and Protocol.ParseUrl(Src, Tab.Parsed)
return Parsed and Protocol.FormatUrl(Parsed), Parsed
end
-- Lays the tab's page out for the current view size.
function Nav.Rebuild(Tab)
if (not Tab.Doc or not S.ViewW) then return end
Tab.Page = Layout.Build(Tab.Doc, S.ViewW, S.ViewH, {
State = Tab.State,
Focus = Tab.Focus,
Images = function(Src)
local Key = Nav.ImageKey(Tab, Src)
if (not Key) then return false end
return S.Images[Key]
end
})
Tab.Built = S.ViewW .. "x" .. S.ViewH
Tab.Scroll = math.max(0, math.min(Tab.Scroll, Tab.Page.MaxScroll))
if (S.Find and Tab == Nav.Tab()) then Nav.FindMatches(false) end
Page:Invalidate()
end
function Nav.UpdateTitle()
local Tab = Nav.Tab()
if (not Tab) then return end
local Title = Tab.Loading and "Loading..." or (Tab.Doc and Tab.Title) or "Savannah"
BlockOS.SetTitle(Title)
end
function Nav.LoadImages(Tab)
if (Tab.ImageTask or not Tab.Doc) then return end
local Pending, Seen = {}, {}
for _, Src in ipairs(CollectImages(Tab.Doc.Blocks, {})) do
local Key, Parsed = Nav.ImageKey(Tab, Src)
if (Key and S.Images[Key] == nil and not Seen[Key]) then
Seen[Key] = true
Pending[#Pending + 1] = { Key = Key, Parsed = Parsed }
end
end
if (#Pending == 0) then return end
if (S.ImageCount + #Pending > C.MaxImages) then
S.Images, S.ImageCount = {}, 0
end
local Task
Task = Tasks.Spawn(function()
for _, Item in ipairs(Pending) do
local Ok, Result = pcall(Nav.Request, Item.Parsed, "GET", nil, { Accept = "image/*", Timeout = 5 })
local Picture = false
if (Ok and Result and Result.Status == 200 and type(Result.Body) == "string") then
local Kind = (Result.Headers["Content-Type"] or ""):match("^image/(%w+)") or Item.Parsed.Path
Picture = Image.Parse(Result.Body, Kind) or false
end
S.Images[Item.Key] = Picture
S.ImageCount = S.ImageCount + 1
Nav.Rebuild(Tab)
end
end, function()
Tab.ImageTask = nil
end)
if (not Task.Done) then Tab.ImageTask = Task end
end
-- Puts a document in a tab. Keep holds the scroll position (reload, back and forward).
function Nav.Show(Tab, Doc, Result, Url, Scroll)
Tab.Doc, Tab.Response, Tab.Url = Doc, Result, Url
Tab.Parsed = Protocol.ParseUrl(Url)
Tab.State, Tab.Focus, Tab.Selected = {}, nil, nil
Tab.Scroll = Scroll or 0
Tab.Title = Doc.Title or "Untitled"
Tab.Status = Result and Result.Status or 0
Tab.Problems = #Doc.Errors
Nav.Rebuild(Tab)
if (Scroll and Tab.Page) then Tab.Scroll = math.min(Scroll, Tab.Page.MaxScroll) end
if (Tab == Nav.Tab()) then Nav.UpdateTitle() end
Nav.LoadImages(Tab)
end
function Nav.Remember(Url, Title)
local Scheme = Url:match("^(%a+)://")
if (Scheme == "savannah") then return end
local History = S.Data.History
local Visits = 0
for Index = #History, 1, -1 do
if (History[Index].Url == Url) then
Visits = History[Index].Visits or 1
table.remove(History, Index)
end
end
table.insert(History, 1, { Url = Url, Title = Title, Time = os.epoch("utc"), Visits = Visits + 1 })
while (#History > C.MaxHistory) do table.remove(History) end
SaveData()
end
function Nav.Finish(Tab, Result, Parsed, Problem, Opts)
local Url = Protocol.FormatUrl(Parsed)
local Doc
if (not Result) then
Doc = Ccmd.Parse(Nav.ConnectError(Url, Problem))
Result = { Status = 0, Headers = {}, Body = nil }
else
Doc = Nav.Interpret(Result, Parsed)
end
if (Opts.Replace and Tab.History[Tab.Index]) then
local Entry = Tab.History[Tab.Index]
Entry.Url, Entry.Response, Entry.Title = Url, Result, Doc.Title
else
for Index = #Tab.History, Tab.Index + 1, -1 do Tab.History[Index] = nil end
Tab.History[#Tab.History + 1] = { Url = Url, Response = Result, Title = Doc.Title, Scroll = 0 }
while (#Tab.History > C.MaxTabHistory) do table.remove(Tab.History, 1) end
Tab.Index = #Tab.History
for Index = 1, #Tab.History - C.CachedPages do Tab.History[Index].Response = nil end
end
Nav.Show(Tab, Doc, Result, Url, Opts.Scroll)
if (Result.Status > 0 and Result.Status < 400) then Nav.Remember(Url, Doc.Title) end
end
function Nav.Stop(Tab)
if (Tab.Task) then
Tasks.Cancel(Tab.Task)
Tab.Task = nil
end
Tab.Loading = nil
Nav.UpdateTitle()
Page:Invalidate()
end
-- Opens an address in a tab. Opts: Method, Body, Absolute (ignore the current page),
-- Replace (reload in place) and Scroll.
function Nav.Go(Tab, Target, Opts)
Opts = Opts or {}
Target = tostring(Target or ""):gsub("^%s+", ""):gsub("%s+$", "")
if (Target == "") then return end
if (Opts.Absolute or not Tab.Parsed) then
local Path = Target:match("^file://(/.*)$") or Target
if (not Path:find("://", 1, true) and Path:find("/", 1, true) and fs.exists(Path)) then
Nav.OpenLocal(Tab, Path)
return
end
end
local Parsed = Protocol.ParseUrl(Target, (not Opts.Absolute) and Tab.Parsed or nil)
if (not Parsed) then
Say("That address could not be read")
return
end
if (Parsed.Scheme == "savannah" and Parsed.Host == "save") then
Nav.SavePage(Tab)
return
end
Nav.Stop(Tab)
if (Tab.History[Tab.Index]) then Tab.History[Tab.Index].Scroll = Tab.Scroll end
Tab.Loading = { Received = 0, Total = 0, Started = os.clock(), Url = Protocol.FormatUrl(Parsed) }
Tab.Focus, Tab.Selected = nil, nil
Nav.UpdateTitle()
local Task
Task = Tasks.Spawn(function()
return Nav.Fetch(Tab, Parsed, Opts.Method or "GET", Opts.Body)
end, function(Ok, Result, Final, Problem)
Tab.Task, Tab.Loading = nil, nil
if (not Ok) then Result, Final, Problem = nil, Parsed, Result end
Nav.Finish(Tab, Result, Final or Parsed, Problem, Opts)
Nav.UpdateTitle()
Page:Invalidate()
end)
if (not Task.Done) then Tab.Task = Task end
Page:Invalidate()
end
function Nav.OpenLocal(Tab, Path)
Path = "/" .. fs.combine(Path, "")
if (fs.isDir(Path)) then
local Root = Site.FindRoot(fs.combine(Path, "index.ccmd"))
Nav.Go(Tab, "local://" .. Nav.SiteKey(Root) .. "/", { Absolute = true })
return
end
local Root = Site.FindRoot(Path)
local Key = Nav.SiteKey(Root)
local Server = Nav.LocalSite(Key)
Server:Refresh()
local Address = Server:Address(Path) or "/"
if (Server:Resolve(Address) ~= Server:SitePath(Path)) then Address = Server:SitePath(Path) or "/" end
Nav.Go(Tab, "local://" .. Key .. Protocol.Escape(Address), { Absolute = true })
end
function Nav.GoHistory(Tab, Index)
local Entry = Tab.History[Index]
if (not Entry) then return end
Nav.Stop(Tab)
if (Tab.History[Tab.Index]) then Tab.History[Tab.Index].Scroll = Tab.Scroll end
Tab.Index = Index
if (Entry.Response) then
local Parsed = Protocol.ParseUrl(Entry.Url)
Nav.Show(Tab, Nav.Interpret(Entry.Response, Parsed), Entry.Response, Entry.Url, Entry.Scroll)
else
Nav.Go(Tab, Entry.Url, { Absolute = true, Replace = true, Scroll = Entry.Scroll })
end
end
function Nav.Reload(Tab)
if (Tab.Loading) then
Nav.Stop(Tab)
return
end
if (not Tab.Url) then return end
if (Tab.Doc) then
for _, Src in ipairs(CollectImages(Tab.Doc.Blocks, {})) do
local Key = Nav.ImageKey(Tab, Src)
if (Key) then S.Images[Key] = nil end
end
end
Nav.Go(Tab, Tab.Url, { Absolute = true, Replace = true, Scroll = Tab.Scroll })
end
function Nav.OpenTab(Target, Background)
local Tab = Nav.NewTabState()
table.insert(S.Tabs, Tab)
if (not Background) then S.Current = #S.Tabs end
View.Relayout()
Nav.Go(Tab, Target or C.StartPage, { Absolute = true })
if (not Background and not Target) then Page:SetFocus(S.W.Address) end
return Tab
end
function Nav.CloseTab(Index)
local Tab = S.Tabs[Index or S.Current]
if (not Tab) then return end
Nav.Stop(Tab)
if (Tab.ImageTask) then Tasks.Cancel(Tab.ImageTask) end
table.remove(S.Tabs, Index or S.Current)
if (#S.Tabs == 0) then
BlockOS.Quit()
Page:Stop()
return
end
S.Current = math.max(1, math.min(S.Current, #S.Tabs))
View.Relayout()
Nav.UpdateTitle()
end
function Nav.SelectTab(Index)
if (not S.Tabs[Index]) then return end
S.Current = Index
if (S.Find) then Nav.FindMatches(true) end
Nav.UpdateTitle()
Page:SetFocus(S.W.View)
Page:Invalidate()
end
function Nav.ToggleFavourite(Tab)
if (not Tab.Url or Tab.Url:match("^savannah://")) then return end
local Index = Nav.FavouriteIndex(Tab.Url)
if (Index) then
table.remove(S.Data.Favourites, Index)
Say("Removed from Favourites")
else
table.insert(S.Data.Favourites, { Title = Tab.Title or Tab.Url, Url = Tab.Url })
Say("Added to Favourites")
end
SaveData()
end
function Nav.SavePage(Tab)
local Body = Tab.Response and Tab.Response.Body
if (type(Body) ~= "string") then
Say("Nothing to save")
return
end
local Name = Tab.Parsed and Tab.Parsed.Path:match("[^/]+$")
if (not Name or Name == "") then
Name = ((Tab.Parsed and Tab.Parsed.Host ~= "" and Tab.Parsed.Host) or "page"):gsub("[^%w%-]", "_") .. ".ccmd"
elseif (not Name:find("%.")) then
Name = Name .. ".ccmd"
end
if (not fs.exists("/home/Downloads")) then fs.makeDir("/home/Downloads") end
local Path = Ui.PickFile({ Title = "Save Page As", Save = true, Dir = "/home/Downloads", Name = Name })
if (not Path) then return end
local Handle = fs.open(Path, "wb")
if (not Handle) then
Ui.Alert("Save", "Could not write " .. Path)
return
end
Handle.write(Body)
Handle.close()
Say("Saved " .. fs.getName(Path))
end
--
-- Forms
--
-- Reads a local file or folder into { [relative path] = contents } for a file field.
local function ReadLocal(Path)
local Files, Total, Count = {}, 0, 0
local function Add(Full, Name)
local Data = Site.ReadFile(Full)
if (not Data) then return end
Files[Name] = Data
Total = Total + #Data
Count = Count + 1
end
local function Walk(Dir, Prefix)
for _, Name in ipairs(fs.list(Dir)) do
local Full = fs.combine(Dir, Name)
if (fs.isDir(Full)) then
if (Name:sub(1, 1) ~= ".") then Walk(Full, Prefix .. Name .. "/") end
else
Add(Full, Prefix .. Name)
end
end
end
if (fs.isDir(Path)) then Walk(Path, "") else Add(Path, fs.getName(Path)) end
if (Count == 0) then return nil, "There are no files there." end
return Files, Total, Count
end
function Nav.Submit(Tab, FormIndex, Button)
local Form = Tab.Doc and Tab.Doc.Forms[FormIndex]
if (not Form) then return end
local Fields = Ccmd.FormFields(Tab.Doc, FormIndex, Tab.State, Button)
local Action = Form.Action or Tab.Url
if (Form.Method == "GET") then
local Parsed = Protocol.ParseUrl(Action, Tab.Parsed)
if (not Parsed) then return end
Parsed.Query = Protocol.EncodeQuery(Fields)
Nav.Go(Tab, Protocol.FormatUrl(Parsed), { Absolute = true })
else
Nav.Go(Tab, Action, { Method = "POST", Body = Fields })
end
end
function Nav.FocusInput(Tab, Input)
Tab.Focus = Input and Input.Id or nil
Nav.Rebuild(Tab)
if (not Input) then return end
local Region = Layout.InputRegion(Tab.Page, Input.Id)
if (Region) then Tab.Scroll = Layout.ScrollTo(Tab.Page, Region.Y, Tab.Scroll) end
end
function Nav.NextField(Tab, Input, Wrap)
local Found, First = false, nil
for _, Other in ipairs(Tab.Doc.Inputs) do
local Typable = Other.Form == Input.Form and (Other.Kind == "text" or Other.Kind == "password")
if (Typable and not First) then First = Other end
if (Found and Typable) then return Other end
if (Other == Input) then Found = true end
end
if (Wrap and First ~= Input) then return First end
return nil
end
function Nav.ChooseFiles(Tab, Input, X, Y)
local Choice = Ui.ContextMenu(X, Y, { "Choose File...", "Choose Folder...", { Text = "Clear", Disabled = Tab.State[Input.Id] == nil } })
local Path
if (Choice == 1) then
Path = Ui.PickFile({ Title = "Choose File", Dir = "/home" })
elseif (Choice == 2) then
Path = Ui.PickFile({ Title = "Choose Folder", Dir = "/home", Folders = true })
elseif (Choice == 3) then
Tab.State[Input.Id] = nil
end
if (Path) then
local Files, Total, Count = ReadLocal(Path)
if (not Files) then
Ui.Alert("Upload", Total)
elseif (Total > C.MaxUpload) then
Ui.Alert("Upload", "That is " .. Gfx.FormatSize(Total) .. ". A site holds about " .. Gfx.FormatSize(C.MaxUpload) .. ".")
else
Tab.State[Input.Id] = Files
Say(Count .. (Count == 1 and " file" or " files") .. " ready")
end
end
Nav.Rebuild(Tab)
end
-- Follows a link, presses a button or starts editing a field. X, Y place menus.
function Nav.Activate(Tab, Region, NewTab, X, Y)
if (Region.Kind == "link" or (Region.Kind == "button" and Region.Button.Href)) then
local Href = Region.Href or Region.Button.Href
if (NewTab) then
local Parsed = Protocol.ParseUrl(Href, Tab.Parsed)
if (Parsed) then
Nav.OpenTab(Protocol.FormatUrl(Parsed), true)
Say("Opened in a new tab")
end
else
Nav.Go(Tab, Href)
end
elseif (Region.Kind == "button" and Region.Button.Form) then
Nav.Submit(Tab, Region.Button.Form, Region.Button)
elseif (Region.Kind == "input") then
if (Region.Input.Kind == "file") then
Nav.ChooseFiles(Tab, Region.Input, X or 2, Y or 2)
else
Nav.FocusInput(Tab, Region.Input)
end
end
end
-- Typing into the focused field. Returns true when handled.
function Nav.EditField(Tab, Name, A)
local Input
for _, Other in ipairs(Tab.Doc.Inputs) do
if (Other.Id == Tab.Focus) then Input = Other end
end
if (not Input) then
Tab.Focus = nil
return false
end
local Value = Tab.State[Input.Id]
if (type(Value) ~= "string") then Value = Input.Value or "" end
if (Name == "char" or Name == "paste") then
if (Ui.Modifiers.Ctrl) then return true end
Value = (Value .. A:gsub("[\r\n]", "")):sub(1, C.MaxField)
elseif (Name == "key") then
if (A == keys.backspace) then
Value = Value:sub(1, -2)
elseif (A == keys.enter or A == keys.numPadEnter) then
local Next = Nav.NextField(Tab, Input, false)
if (Next) then
Nav.FocusInput(Tab, Next)
elseif (Input.Form) then
Tab.Focus = nil
Nav.Submit(Tab, Input.Form, nil)
else
Nav.FocusInput(Tab, nil)
end
return true
elseif (A == keys.tab) then
Nav.FocusInput(Tab, Nav.NextField(Tab, Input, true))
return true
elseif (A == keys.up or A == keys.down) then
Nav.FocusInput(Tab, nil)
return false
else
return true
end
else
return false
end
Tab.State[Input.Id] = Value
Nav.Rebuild(Tab)
return true
end
--
-- Find on page
--
function Nav.FindMatches(Jump)
local Find, Tab = S.Find, Nav.Tab()
Find.Matches = {}
local Query = Find.Query:lower()
if (Query == "" or not Tab or not Tab.Page) then
Find.Index = 0
return
end
for Row, Line in ipairs(Tab.Page.Rows) do
local Text = Line[1]:lower()
local From = 1
while (#Find.Matches < C.MaxMatches) do
local Start, Finish = Text:find(Query, From, true)
if (not Start) then break end
Find.Matches[#Find.Matches + 1] = { Y = Row, X1 = Start, X2 = Finish }
From = Finish + 1
end
end
if (Jump or Find.Index > #Find.Matches or Find.Index < 1) then
Find.Index = #Find.Matches > 0 and 1 or 0
if (Jump) then Nav.FindStep(0) end
end
end
function Nav.FindStep(Step)
local Find, Tab = S.Find, Nav.Tab()
if (not Find or #Find.Matches == 0) then return end
Find.Index = (Find.Index - 1 + Step) % #Find.Matches + 1
local Match = Find.Matches[Find.Index]
Tab.Scroll = Layout.ScrollTo(Tab.Page, Match.Y, Tab.Scroll)
Page:Invalidate()
end
function Nav.OpenFind()
if (not S.Find) then
S.Find = { Query = "", Matches = {}, Index = 0 }
View.Relayout()
end
Page:SetFocus(S.W.FindField)
S.W.FindField:SelectAll()
end
function Nav.CloseFind()
S.Find = nil
View.Relayout()
end
--
-- Built-in savannah:// pages
--
-- Wraps a built-in page. Leading spaces become non-breaking spaces so paragraphs keep
-- their margin (ccmd removes indentation shared by every line of a paragraph).
function Internal.Source(Title, Body)
local Th = Theme.Get()
for Index, Line in ipairs(Body) do
Body[Index] = Line:gsub("^( +)", function(Spaces) return string.rep("\160", #Spaces) end)
end
return table.concat({
"--", "title: " .. Title, "location: /",
"background: " .. (Th.Dark and "black" or "white"),
"text-color: " .. (Th.Dark and "white" or "black"),
"link-color: " .. (Th.Dark and "lightBlue" or "blue"),
"--",
table.concat(Body, "\n")
}, "\n")
end
local function Heading(Body, Text)
Body[#Body + 1] = " "
Body[#Body + 1] = "
"
if (#S.Data.Favourites == 0) then
Body[#Body + 1] = " {gray}Press * in the toolbar to add the page you are on.{/}"
end
for _, Entry in ipairs(S.Data.Favourites) do
Body[#Body + 1] = " {orange}\4{/} " .. LinkSource(Entry.Title, Entry.Url)
end
Body[#Body + 1] = "
"
local Frequent = {}
for _, Entry in ipairs(S.Data.History) do
if (not Nav.FavouriteIndex(Entry.Url)) then Frequent[#Frequent + 1] = Entry end
end
table.sort(Frequent, function(A, B) return (A.Visits or 1) > (B.Visits or 1) end)
if (#Frequent > 0) then
Heading(Body, "Frequently Visited")
Body[#Body + 1] = "
"
for Index = 1, math.min(6, #Frequent) do
local Entry = Frequent[Index]
Body[#Body + 1] = " {gray}\7{/} " .. LinkSource(Entry.Title or Entry.Url, Entry.Url) .. " {gray}" .. Ccmd.Escape(ShortUrl(Entry.Url)) .. "{/}"
end
Body[#Body + 1] = "
"
end
Heading(Body, "Explore")
Body[#Body + 1] = "
"
return Internal.Source("Start Page", Body)
end
function Internal.Favourites()
local Body = { " ", "
", "{orange}\4{/} Favourites", "
", " ", "
" }
if (#S.Data.Favourites == 0) then
Body[#Body + 1] = " {gray}No favourites yet. Press * in the toolbar to add the page you are on.{/}"
end
for Index, Entry in ipairs(S.Data.Favourites) do
Body[#Body + 1] = " " .. LinkSource(Entry.Title, Entry.Url) .. " {gray}" .. Ccmd.Escape(ShortUrl(Entry.Url)) .. "{/} " .. LinkSource("Remove", "savannah://favourites?remove=" .. Index)
end
Body[#Body + 1] = "
"
return Internal.Source("Favourites", Body)
end
function Internal.History()
local Body = { " ", "
", "History", "
" }
local Day
if (#S.Data.History == 0) then
Body[#Body + 1] = " "
Body[#Body + 1] = "
"
Body[#Body + 1] = "{gray}Pages you visit show up here.{/}"
Body[#Body + 1] = "
"
end
for _, Entry in ipairs(S.Data.History) do
local Seconds = math.floor((Entry.Time or 0) / 1000)
local Date = os.date("%A %d %B", Seconds)
if (Date ~= Day) then
if (Day) then Body[#Body + 1] = "
" end
Heading(Body, Date)
Body[#Body + 1] = "
"
Day = Date
end
Body[#Body + 1] = " {gray}" .. os.date("%H:%M", Seconds) .. "{/} " .. LinkSource(Entry.Title or Entry.Url, Entry.Url) .. " {gray}" .. Ccmd.Escape(ShortUrl(Entry.Url)) .. "{/}"
end
if (Day) then
Body[#Body + 1] = "