-- 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] = "

" Body[#Body + 1] = " " .. Ccmd.Escape(Text) Body[#Body + 1] = "

" end local function ShortUrl(Url) return (Url:gsub("^ccmd://", "")) end function Internal.Start() local Body = { "
", "

", "{orange}\15{/} {yellow}Savannah{/}", "{gray}Browse the ccinternet{/}", "

" } Heading(Body, "Favourites") 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] = "

" Body[#Body + 1] = " " .. LinkSource("Every site", "domain://directory") .. " " .. LinkSource("Get a domain", "domain://") .. " " .. LinkSource("History", "savannah://history") 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] = "

" Body[#Body + 1] = "
" Body[#Body + 1] = "