-- =============================================================
-- OCBrowser v2.0 | Браузер для OpenComputers
-- Требования: Internet Card, GPU Tier 2+, Screen Tier 2+
-- =============================================================
local component = require("component")
local term = require("term")
local gpu = component.gpu
local event = require("event")
local unicode = require("unicode")
local keyboard = require("keyboard")
local text = require("text")
local computer = require("computer")
local _okFs, fs = pcall(require, "filesystem")
if not _okFs then fs = nil end
-- ── проверка internet card ────────────────────────────────────
if not component.isAvailable("internet") then
io.stderr:write("Ошибка: Internet Card не найдена!\n")
os.exit(1)
end
local inet = component.internet
-- ── размеры экрана ───────────────────────────────────────────
local W, H = gpu.getResolution()
local PAGE_Y = 4 -- начало области страницы (строка)
local PAGE_H = H - PAGE_Y -- количество строк страницы
local PAGE_W = W - 2 -- с учётом скроллбара
-- ── цветовая палитра ─────────────────────────────────────────
local C = {
bg = 0x1a1a2e,
bar = 0x16213e,
urlBox = 0x0f3460,
urlText = 0xe0e0e0,
accent = 0x533483,
btnBg = 0x0f3460,
btnFg = 0xffffff,
btnHover = 0x533483,
pageBg = 0x1e1e2e,
pageText = 0xcdd6f4,
heading = 0xcba6f7,
link = 0x89b4fa,
linkVisit = 0xb4befe,
error = 0xf38ba8,
status = 0xa6adc8,
scrollbar = 0x313244,
scrollFg = 0x585b70,
select = 0x313244,
}
-- ── состояние браузера ────────────────────────────────────────
local state = {
url = "about:home",
history = {},
histPos = 0,
lines = {}, -- отрендеренные строки {text, color, isLink, href}
scroll = 0,
urlBar = "",
urlFocus = false,
urlCursor = 0,
loading = false,
loadProgress = nil,
loadReceived = 0,
loadTotal = 0,
fetchJob = nil,
statusMsg = "Добро пожаловать в OCBrowser",
links = {}, -- список ссылок на странице
selLink = -1,
pageH = H - 4, -- высота области страницы
forms = {}, -- формы текущей страницы
inputs = {}, -- поля ввода (любой формы)
inputEdit = nil, -- {idx, buf, cursor} когда активно редактирование
}
-- ── домашняя страница ────────────────────────────────────────
local HOME_PAGE = [[
OCBrowser — Домашняя
OCBrowser 2.1
Добро пожаловать!
Текстовый браузер для OpenComputers . Является «Lynx-подобным» — JS не выполняется .
Поиск (работает без JS)
Совет : добавьте в запрос !g, !w, !yt — DDG-бэнги для Google/Wikipedia/YouTube.
Reader Mode (для JS-сайтов)
На любой странице нажмите Ctrl+R — страница будет перезагружена через r.jina.ai , который исполнит JS на своём сервере и отдаст чистый Markdown. Работает для Telegram, Twitter, Reddit и большинства SPA.
[Пример] web.telegram.org через Reader
Быстрые ссылки (работают без JS)
Поддержка разметки
Теперь работает: жирный , курсив , моноширинный код, подчёркивание , выделение , зачёркнутый , ссылки внутри абзаца.
«Цитата выглядит как блок с вертикальной полосой слева».
Нумерованный список:
Первый пункт
Второй пункт с ссылкой
Третий пункт
Блок <pre> сохраняет пробелы и переносы:
function hello()
return "world"
end
Изображения рендерятся в реальном времени (PNG → inflate → ½-блоки):
Управление
F5 — обновить | Alt+Left — назад | Alt+Right — вперёд
Tab — следующая ссылка | Enter — открыть ссылку / URL
PgUp/PgDn или стрелки — прокрутка | Ctrl+L — адресная строка
]]
-- ═══════════════════════════════════════════════════════════════
-- УТИЛИТЫ ОТРИСОВКИ
-- ═══════════════════════════════════════════════════════════════
local function setFg(c) gpu.setForeground(c) end
local function setBg(c) gpu.setBackground(c) end
local drawPage
local renderPage
processNextImage = nil -- объявление в общем пространстве (присваивается позже)
local function fill(x, y, w, h, ch)
gpu.fill(x, y, w, h, ch or " ")
end
local function writeAt(x, y, str, fg, bg)
if fg then setFg(fg) end
if bg then setBg(bg) end
gpu.set(x, y, str)
end
local function clampStr(s, maxW)
if unicode.len(s) <= maxW then return s end
return unicode.sub(s, 1, maxW - 1) .. "…"
end
local function center(s, w)
local l = unicode.len(s)
if l >= w then return unicode.sub(s, 1, w) end
local pad = math.floor((w - l) / 2)
return string.rep(" ", pad) .. s .. string.rep(" ", w - l - pad)
end
-- ═══════════════════════════════════════════════════════════════
-- ТОП-БАР
-- ═══════════════════════════════════════════════════════════════
local BTN = {
{ label = " ◄ ", key = "back", x = 2 },
{ label = " ► ", key = "fwd", x = 6 },
{ label = " ↺ ", key = "reload", x = 10 },
{ label = " ⌂ ", key = "home", x = 14 },
}
local URL_X = 19
local URL_W = W - 22
local GO_X = W - 2
local function drawBar()
setBg(C.bar) setFg(C.urlText)
fill(1, 1, W, 1)
fill(1, 2, W, 1)
fill(1, 3, W, 1)
-- кнопки навигации
for _, b in ipairs(BTN) do
local active = true
if b.key == "back" then active = state.histPos > 1 end
if b.key == "fwd" then active = state.histPos < #state.history end
setBg(C.btnBg)
setFg(active and C.btnFg or C.status)
gpu.set(b.x, 2, b.label)
end
-- адресная строка / промпт инпута
local ie = state.inputEdit
local inEdit = ie ~= nil
local boxBg = (state.urlFocus or inEdit) and C.accent or C.urlBox
setBg(boxBg) setFg(C.urlText)
fill(URL_X, 2, URL_W, 1)
local displayText, cursorPos
if inEdit then
local rec = state.inputs[ie.idx]
local label = "[" .. (rec and rec.name ~= "" and rec.name or ("input #" .. ie.idx)) .. "] "
displayText = label .. ie.buf
cursorPos = unicode.len(label) + ie.cursor
elseif state.urlFocus then
displayText = state.urlBar
cursorPos = state.urlCursor
else
displayText = state.url
cursorPos = nil
end
local shown = clampStr(displayText, URL_W - 2)
gpu.set(URL_X + 1, 2, shown)
if cursorPos then
local cx = URL_X + 1 + math.min(cursorPos, URL_W - 3)
setBg(C.urlText) setFg(boxBg)
local src = inEdit and ie.buf or state.urlBar
local off = inEdit and (cursorPos) or state.urlCursor
local ch
if inEdit then
ch = unicode.sub(ie.buf, ie.cursor + 1, ie.cursor + 1)
else
ch = unicode.sub(state.urlBar, state.urlCursor + 1, state.urlCursor + 1)
end
gpu.set(cx, 2, ch == "" and " " or ch)
end
-- кнопка GO
setBg(C.accent) setFg(C.btnFg)
gpu.set(GO_X, 2, " ▶")
-- строка статуса
setBg(C.bar) setFg(C.status)
fill(1, 3, W, 1)
local loading = state.loading and "" or ""
local left = clampStr(state.statusMsg .. loading, W - 2)
gpu.set(2, 3, left)
if state.loading then
local barW = math.min(26, math.max(10, math.floor(W * 0.22)))
local barX = W - barW - 1
local filled = 0
local suffix = ""
if type(state.loadProgress) == "number" then
filled = math.floor((barW - 2) * math.max(0, math.min(1, state.loadProgress)))
suffix = string.format(" %3d%%", math.floor(state.loadProgress * 100 + 0.5))
else
local phase = (state.loadReceived or 0) % math.max(1, (barW - 2))
filled = math.max(1, phase)
suffix = " ..."
end
setBg(C.bar) setFg(C.status)
gpu.set(barX, 3, "[")
gpu.set(barX + barW - 1, 3, "]")
setBg(C.scrollFg) setFg(C.btnFg)
if filled > 0 then gpu.set(barX + 1, 3, string.rep(" ", math.min(barW - 2, filled))) end
setBg(C.bar) setFg(C.status)
local rest = (barW - 2) - math.min(barW - 2, filled)
if rest > 0 then gpu.set(barX + 1 + math.min(barW - 2, filled), 3, string.rep(" ", rest)) end
gpu.set(math.max(2, barX - unicode.len(suffix)), 3, suffix)
end
end
-- ═══════════════════════════════════════════════════════════════
-- РАЗБОР HTML → строки
-- ═══════════════════════════════════════════════════════════════
-- ── HTML entities ─────────────────────────────────────────────
local ENTITIES = {
amp="&", lt="<", gt=">", quot='"', apos="'", nbsp=" ",
mdash="—", ndash="–", hellip="…", laquo="«", raquo="»",
copy="©", reg="®", trade="™",
euro="€", pound="£", yen="¥", cent="¢",
sect="§", para="¶", middot="·", bull="•", deg="°",
larr="←", rarr="→", uarr="↑", darr="↓", harr="↔",
lsquo="‘", rsquo="’", ldquo="“", rdquo="”", sbquo="‚", bdquo="„",
times="×", divide="÷", plusmn="±",
["ne"]="≠", le="≤", ge="≥",
infin="∞", sum="∑", radic="√", micro="µ",
}
local function htmlDecode(s)
s = s:gsub("(%d+);", function(n)
local v = tonumber(n); if not v then return "" end
local ok, ch = pcall(unicode.char, v); return ok and ch or ""
end)
s = s:gsub("[xX](%x+);", function(n)
local v = tonumber(n, 16); if not v then return "" end
local ok, ch = pcall(unicode.char, v); return ok and ch or ""
end)
s = s:gsub("&(%a+);", function(name)
return ENTITIES[name] or ("&" .. name .. ";")
end)
return s
end
-- ── токенизатор HTML → плоский поток событий ──────────────────
local function tokenizeHTML(html)
local tokens = {}
local stack = { { fg=nil, bg=nil, bold=false, italic=false, code=false,
underline=false, strike=false, mark=false, small=false,
link=nil, heading=0, preformat=false, skip=false } }
local function cur() return stack[#stack] end
local function push(mod)
local top = stack[#stack]
local nw = {}
for k,v in pairs(top) do nw[k] = v end
for k,v in pairs(mod) do nw[k] = v end
stack[#stack+1] = nw
end
local function pop()
if #stack > 1 then stack[#stack] = nil end
end
local inHead, inScript, inStyle = false, false, false
local listTypes = {} -- стек: "ul"/"ol"
local olCounters = {}
-- формы / инпуты
local forms = {}
local inputs = {}
local currentFormIdx = nil
local function regInput(rec)
inputs[#inputs + 1] = rec
rec._idx = #inputs
if currentFormIdx and forms[currentFormIdx] then
local f = forms[currentFormIdx]
f.inputs[#f.inputs + 1] = rec._idx
rec.formIdx = currentFormIdx
end
return rec._idx
end
local function emitText(t)
if t == "" then return end
tokens[#tokens+1] = { kind="text", text=t, style=cur() }
end
local function emit(kind, extra)
local t = extra or {}
t.kind = kind
tokens[#tokens+1] = t
end
local pos = 1
local len = #html
while pos <= len do
local tagStart = html:find("<", pos, true)
if not tagStart then
if not (inHead or inScript or inStyle) then
emitText(htmlDecode(html:sub(pos)))
end
break
end
if tagStart > pos and not (inHead or inScript or inStyle) then
emitText(htmlDecode(html:sub(pos, tagStart - 1)))
end
-- комментарий
if html:sub(tagStart, tagStart+3) == "", tagStart+4, true)
if not cend then break end
pos = cend + 3
else
local tagEnd = html:find(">", tagStart, true)
if not tagEnd then break end
local raw = html:sub(tagStart+1, tagEnd-1)
pos = tagEnd + 1
-- самозакрывающийся
local selfClose = raw:sub(-1) == "/"
if selfClose then raw = raw:sub(1, -2):gsub("%s+$", "") end
local isClose = raw:sub(1,1) == "/"
local tname = raw:match("^/?%s*([%w!]+)") or ""
tname = tname:lower()
local function attr(name)
return raw:match(name .. '%s*=%s*"([^"]*)"')
or raw:match(name .. "%s*=%s*'([^']*)'")
or raw:match(name .. "%s*=%s*([^%s>]+)")
end
if tname == "head" then inHead = not isClose
elseif tname == "script" then inScript = not isClose
elseif tname == "style" then inStyle = not isClose
elseif inHead or inScript or inStyle then
-- пропускаем содержимое
elseif tname == "br" then
emit("br")
elseif tname == "hr" then
emit("hr")
elseif tname == "img" then
emit("img", { src = attr("src") or "", alt = attr("alt") or "", title = attr("title") })
elseif tname == "p" or tname == "div" or tname == "section"
or tname == "article" or tname == "header" or tname == "footer"
or tname == "main" or tname == "nav" or tname == "aside"
or tname == "figure" or tname == "figcaption"
or tname == "address" or tname == "dl" or tname == "dt" or tname == "dd" then
emit("parabr")
if not isClose then
local mods = {}
local al = attr("align")
if al then mods.align = al:lower() end
push(mods)
else
pop()
end
elseif tname == "form" then
emit("parabr")
if not isClose then
forms[#forms + 1] = {
action = attr("action") or "",
method = (attr("method") or "get"):lower(),
inputs = {},
enctype = (attr("enctype") or "application/x-www-form-urlencoded"):lower(),
}
currentFormIdx = #forms
push{} -- form-scope
else
pop()
currentFormIdx = nil
emit("parabr")
end
elseif tname == "input" then
if not (inHead or inScript or inStyle) then
local itype = (attr("type") or "text"):lower()
local rec = {
type = itype,
name = attr("name") or "",
value = attr("value") or "",
placeholder = attr("placeholder") or "",
size = tonumber(attr("size")) or 0,
checked = attr("checked") ~= nil,
}
local idx = regInput(rec)
if itype == "hidden" then
-- невидимое поле
elseif itype == "submit" or itype == "button" or itype == "image" or itype == "reset" then
-- кнопка: рендерим текстовый лейбл как ссылку-сабмит
local label = "[ " .. (rec.value ~= "" and rec.value
or (itype == "submit" and "Отправить" or "Кнопка")) .. " ]"
local href = (itype == "reset") and ("__reset:" .. idx) or ("__submit:" .. idx)
push{ link = href, button = true }
emitText(label)
pop()
else
-- текстовое поле
local sz = rec.size
if sz <= 0 then sz = 30 end
if sz > 60 then sz = 60 end
if sz < 4 then sz = 4 end
rec.size = sz
emit("input_field", { idx = idx, style = cur() })
end
end
elseif tname == "textarea" then
if not isClose then
local rec = {
type = "textarea",
name = attr("name") or "",
value = "",
placeholder = attr("placeholder") or "",
size = tonumber(attr("cols")) or 40,
checked = false,
}
if rec.size > 60 then rec.size = 60 end
if rec.size < 8 then rec.size = 8 end
local idx = regInput(rec)
emit("input_field", { idx = idx, style = cur() })
push{ skip = true } -- игнорируем дефолтный текст внутри
else
pop()
end
elseif tname == "button" then
if not isClose then
local btype = (attr("type") or "submit"):lower()
local rec = {
type = btype,
name = attr("name") or "",
value = attr("value") or "",
}
local idx = regInput(rec)
local href
if btype == "reset" then href = "__reset:" .. idx
elseif btype == "button" then href = "__button:" .. idx
else href = "__submit:" .. idx end
push{ link = href, button = true }
emitText("[ ")
else
emitText(" ]")
pop()
end
elseif tname == "select" then
-- упрощённо: ведёт себя как контейнер; реальный пикер не реализован
if not isClose then push{ skip = true } else pop() end
elseif tname == "option" then
-- скрыто внутри
elseif tname == "label" then
-- inline (просто текст внутри)
elseif tname == "center" then
emit("parabr")
if not isClose then push{ align = "center" }
else pop(); emit("parabr") end
elseif tname == "h1" or tname == "h2" or tname == "h3"
or tname == "h4" or tname == "h5" or tname == "h6" then
emit("parabr")
if not isClose then
local mods = { heading = tonumber(tname:sub(2)) or 1, bold = true }
local al = attr("align")
if al then mods.align = al:lower() end
push(mods)
else
pop()
emit("parabr")
end
elseif tname == "b" or tname == "strong" then
if not isClose then push{ bold = true } else pop() end
elseif tname == "i" or tname == "em" or tname == "cite" or tname == "var" or tname == "dfn" then
if not isClose then push{ italic = true } else pop() end
elseif tname == "u" or tname == "ins" then
if not isClose then push{ underline = true } else pop() end
elseif tname == "s" or tname == "del" or tname == "strike" then
if not isClose then push{ strike = true } else pop() end
elseif tname == "code" or tname == "tt" or tname == "kbd" or tname == "samp" then
if not isClose then push{ code = true } else pop() end
elseif tname == "pre" then
emit("parabr")
if not isClose then push{ preformat = true, code = true }
else pop(); emit("parabr") end
elseif tname == "blockquote" then
emit("parabr")
if not isClose then emit("quote_open")
else emit("quote_close"); emit("parabr") end
elseif tname == "mark" then
if not isClose then push{ mark = true } else pop() end
elseif tname == "small" or tname == "sub" or tname == "sup" then
if not isClose then push{ small = true } else pop() end
elseif tname == "a" then
if not isClose then
push{ link = attr("href") or "" }
else
pop()
end
elseif tname == "font" or tname == "span" then
if not isClose then
local mods = {}
local col = attr("color")
local st = attr("style")
if col then
local hex = col:match("^#?(%x%x%x%x%x%x)$")
if hex then mods.fg = tonumber(hex, 16) end
end
if st then
local hex = st:match("color%s*:%s*#(%x%x%x%x%x%x)")
if hex then mods.fg = tonumber(hex, 16) end
if st:find("font%-weight%s*:%s*bold") then mods.bold = true end
if st:find("font%-style%s*:%s*italic") then mods.italic = true end
end
push(mods)
else
pop()
end
elseif tname == "ul" then
emit("parabr")
if not isClose then listTypes[#listTypes+1] = "ul"
else listTypes[#listTypes] = nil; emit("parabr") end
elseif tname == "ol" then
emit("parabr")
if not isClose then
listTypes[#listTypes+1] = "ol"
olCounters[#olCounters+1] = 0
else
listTypes[#listTypes] = nil
olCounters[#olCounters] = nil
emit("parabr")
end
elseif tname == "li" then
if not isClose then
local lt = listTypes[#listTypes] or "ul"
local marker
if lt == "ol" then
olCounters[#olCounters] = (olCounters[#olCounters] or 0) + 1
marker = tostring(olCounters[#olCounters]) .. ". "
else
marker = "• "
end
emit("li", { marker = marker, depth = #listTypes })
else
emit("br")
end
elseif tname == "tr" then
if not isClose then emit("br") end
elseif tname == "td" or tname == "th" then
if not isClose then
emitText(" ")
if tname == "th" then push{ bold = true } end
else
if tname == "th" then pop() end
end
elseif tname == "title" then
if not isClose then push{ skip = true } else pop() end
else
-- неизвестный тег: игнорируем
end
end
end
return tokens, forms, inputs
end
-- ── раскладка токенов: word-wrap + инлайн-сегменты ────────────
local function _renderInputText(rec)
local v = rec.value
if (v == nil or v == "") and rec.placeholder ~= "" then
v = rec.placeholder
end
v = v or ""
if rec.type == "password" and rec.value and rec.value ~= "" then
v = string.rep("*", unicode.len(rec.value))
end
local sz = rec.size or 30
local vlen = unicode.len(v)
if vlen > sz then
v = unicode.sub(v, vlen - sz + 1)
else
v = v .. string.rep(" ", sz - vlen)
end
return "[" .. v .. "]"
end
local function layoutTokens(tokens, maxW, inputs)
local lines = {}
local links = {}
local currentAlign = "left"
local curSegs = {}
local curWidth = 0
local lastWasBlank = true
local pendingParaBreak = false
local paragraphIndent = ""
local pendingMarker = nil
local quoteDepth = 0
local function styleToColors(st)
local fg = st.fg or C.pageText
local bg = st.bg or C.pageBg
if st.code then fg = 0xf9e2af; bg = 0x313244 end
if st.heading and st.heading > 0 then fg = C.heading end
if st.link then fg = C.link end
-- bold = ярко-оранжевый, явно отличается от основного текста
if st.bold and not st.heading and not st.link and not st.code then
fg = 0xfab387
end
-- italic = бирюзовый
if st.italic and not st.link and not st.code and not st.heading then
fg = 0x94e2d5
end
-- bold+italic = розовый
if st.bold and st.italic and not st.link and not st.code and not st.heading then
fg = 0xf38ba8
end
if st.mark then bg = 0xf9e2af; fg = 0x1e1e2e end
if st.small then fg = C.status end
-- strike = тусклый
if st.strike and not st.link and not st.code then fg = C.scrollFg end
-- underline = выраженный фон
if st.underline and bg == C.pageBg then bg = 0x585b70 end
return fg, bg
end
local function pushSegRaw(text, fg, bg, linkIdx)
if text == "" then return end
local last = curSegs[#curSegs]
if last and last.fg == fg and last.bg == bg and last.link == linkIdx then
last.text = last.text .. text
else
curSegs[#curSegs+1] = { text = text, fg = fg, bg = bg, link = linkIdx }
end
curWidth = curWidth + unicode.len(text)
end
local function pushSeg(text, st, linkIdx)
local fg, bg = styleToColors(st)
pushSegRaw(text, fg, bg, linkIdx)
end
local function flushLine()
if #curSegs == 0 and lastWasBlank then return end
lines[#lines+1] = { segs = curSegs, align = currentAlign }
lastWasBlank = (#curSegs == 0)
curSegs = {}
curWidth = 0
end
local function ensurePrefix()
if #curSegs == 0 then
if quoteDepth > 0 then
for _ = 1, quoteDepth do
pushSegRaw("▌ ", C.scrollFg, C.pageBg, nil)
end
end
if paragraphIndent ~= "" then
pushSegRaw(paragraphIndent, C.pageText, C.pageBg, nil)
end
if pendingMarker then
pushSegRaw(pendingMarker.text, pendingMarker.fg, C.pageBg, nil)
pendingMarker = nil
end
end
end
local function addBlankLine()
if lastWasBlank then return end
lines[#lines+1] = { segs = {} }
lastWasBlank = true
end
local function applyPara()
if pendingParaBreak then
if #curSegs > 0 then flushLine() end
addBlankLine()
pendingParaBreak = false
end
end
local function addWord(word, st, linkIdx)
if word == "" then return end
applyPara()
ensurePrefix()
local wlen = unicode.len(word)
if curWidth + wlen <= maxW then
pushSeg(word, st, linkIdx); return
end
-- не помещается: перенос строки
if curWidth > (paragraphIndent == "" and quoteDepth * 2 or unicode.len(paragraphIndent) + quoteDepth * 2) then
flushLine(); ensurePrefix()
end
-- если слово длиннее доступной ширины — режем
while unicode.len(word) > 0 and curWidth + unicode.len(word) > maxW do
local room = math.max(1, maxW - curWidth)
pushSeg(unicode.sub(word, 1, room), st, linkIdx)
word = unicode.sub(word, room + 1)
if unicode.len(word) > 0 then flushLine(); ensurePrefix() end
end
if unicode.len(word) > 0 then pushSeg(word, st, linkIdx) end
end
local function addSpace(st, linkIdx)
applyPara()
if #curSegs == 0 then return end
if curWidth >= maxW then return end
pushSeg(" ", st, linkIdx)
end
local function addTextToken(text, st)
if st.align then currentAlign = st.align end
local linkIdx = nil
if st.link and st.link ~= "" then
links[#links+1] = { href = st.link }
linkIdx = #links
end
if st.preformat then
applyPara()
local s = text:gsub("\r\n", "\n"):gsub("\r", "\n")
local first = true
for line in (s .. "\n"):gmatch("([^\n]*)\n") do
if not first then flushLine() end
first = false
ensurePrefix()
local i, l = 1, unicode.len(line)
while i <= l do
local room = maxW - curWidth
if room <= 0 then flushLine(); ensurePrefix(); room = maxW - curWidth end
local chunk = unicode.sub(line, i, i + room - 1)
pushSeg(chunk, st, linkIdx)
i = i + unicode.len(chunk)
end
end
return
end
text = text:gsub("[ \t\r\n]+", " ")
if text == "" then return end
if text == " " then addSpace(st, linkIdx); return end
local startsWithSpace = text:sub(1,1) == " "
local endsWithSpace = text:sub(-1) == " "
if startsWithSpace then addSpace(st, linkIdx) end
local trimmed = text:gsub("^ ", ""):gsub(" $", "")
if trimmed ~= "" then
local firstWord = true
for word in trimmed:gmatch("[^ ]+") do
if not firstWord then addSpace(st, linkIdx) end
addWord(word, st, linkIdx)
firstWord = false
end
end
if endsWithSpace then addSpace(st, linkIdx) end
end
for _, tok in ipairs(tokens) do
if tok.style and tok.style.skip then
-- skip
elseif tok.kind == "text" then
addTextToken(tok.text, tok.style)
elseif tok.kind == "br" then
applyPara()
flushLine()
elseif tok.kind == "parabr" then
pendingParaBreak = true
paragraphIndent = ""
pendingMarker = nil
currentAlign = "left"
elseif tok.kind == "hr" then
applyPara()
flushLine()
lines[#lines+1] = { segs = { { text = string.rep("─", maxW), fg = C.scrollFg, bg = C.pageBg } } }
lastWasBlank = false
pendingParaBreak = true
elseif tok.kind == "li" then
applyPara()
if #curSegs > 0 then flushLine() end
paragraphIndent = string.rep(" ", math.max(0, (tok.depth or 1) - 1))
pendingMarker = { text = tok.marker or "• ", fg = C.scrollFg }
elseif tok.kind == "quote_open" then
quoteDepth = quoteDepth + 1
elseif tok.kind == "quote_close" then
quoteDepth = math.max(0, quoteDepth - 1)
elseif tok.kind == "img" then
applyPara()
if #curSegs > 0 then flushLine() end
local cap = tok.alt
if cap == "" then cap = tok.title or "" end
if cap == "" then cap = tok.src ~= "" and tok.src or "image" end
local label = "⌛ [IMG] " .. cap
if unicode.len(label) > maxW then label = unicode.sub(label, 1, maxW - 1) .. "…" end
ensurePrefix()
local linkIdx = nil
if tok.src and tok.src ~= "" then
links[#links + 1] = { href = tok.src }
linkIdx = #links
end
pushSegRaw(label, C.linkVisit, C.pageBg, linkIdx)
flushLine()
if tok.src and tok.src ~= "" then
lines[#lines]._imgReq = { src = tok.src, alt = cap }
end
pendingParaBreak = true
elseif tok.kind == "input_field" and inputs and inputs[tok.idx] then
local rec = inputs[tok.idx]
if tok.style and tok.style.align then currentAlign = tok.style.align end
local label = _renderInputText(rec)
applyPara()
ensurePrefix()
local need = unicode.len(label)
if curWidth + need > maxW then
flushLine(); ensurePrefix()
end
links[#links + 1] = { href = "__input:" .. tok.idx }
local linkIdx = #links
local seg = { text = label, fg = C.urlText, bg = C.urlBox, link = linkIdx, _inputIdx = tok.idx }
curSegs[#curSegs + 1] = seg
curWidth = curWidth + need
rec._segRef = seg
end
end
if #curSegs > 0 then flushLine() end
-- посчитать первую строку каждой ссылки и lineRef для inputs
for li, line in ipairs(lines) do
for _, seg in ipairs(line.segs) do
if seg.link then
local lk = links[seg.link]
if lk and not lk.firstLine then lk.firstLine = li end
end
if seg._inputIdx and inputs and inputs[seg._inputIdx] then
local rec = inputs[seg._inputIdx]
if not rec._lineRef then rec._lineRef = line end
end
end
end
-- Пост-pass: центрирование/выравнивание-вправо
for _, line in ipairs(lines) do
if line.align == "center" or line.align == "right" then
local w = 0
for _, seg in ipairs(line.segs) do w = w + unicode.len(seg.text) end
if w > 0 and w < maxW then
local pad = (line.align == "center")
and math.floor((maxW - w) / 2)
or (maxW - w)
if pad > 0 then
table.insert(line.segs, 1, { text = string.rep(" ", pad), fg = C.pageText, bg = C.pageBg })
end
end
end
end
return lines, links
end
local function parseHTML(html, maxW)
local tokens, forms, inputs = tokenizeHTML(html)
local lines, links = layoutTokens(tokens, maxW, inputs)
return lines, links, forms, inputs
end
-- ═══════════════════════════════════════════════════════════════
-- HTTP ЗАГРУЗКА
-- ═══════════════════════════════════════════════════════════════
local function resolveUrl(base, href)
if href:match("^https?://") then return href end
if href:match("^//") then
local scheme = base:match("^(https?:)")
return (scheme or "http:") .. href
end
if href:match("^/") then
local origin = base:match("^(https?://[^/]+)")
return (origin or "") .. href
end
-- относительный
local dir = base:match("^(.+/)") or base
return dir .. href
end
-- ── Загрузка и рендер изображений через wsrv.nl + BMP ────────────
local IMG_FETCH_TIMEOUT = 10
local IMG_MAX_W_CHARS = 120 -- ограничение пикселей по горизонтали
local IMG_MAX_H_PIXELS = 80 -- по вертикали (в полублоках = 40 строк)
-- Цепочка прокси. Перебираются последовательно до первого успеха.
-- url_pattern: %s = URL (опцион. urlEncoded); %d %d = maxW maxH (если resize).
-- mode: "resize" (подставить W,H) или "raw" (только URL).
local IMG_PROXIES = {
{ name = "wsrv", mode = "resize", encode = true,
url = "https://wsrv.nl/?url=%s&w=%d&h=%d&fit=inside&output=png" },
{ name = "codetabs", mode = "raw", encode = true,
url = "https://api.codetabs.com/v1/proxy/?quest=%s" },
{ name = "direct", mode = "raw", encode = false,
url = "%s" },
}
local function urlEncode(s)
return (s:gsub("([^%w%-_.~])", function(c)
return string.format("%%%02X", c:byte())
end))
end
local function _readU(data, off, n)
local v, mul = 0, 1
for i = 0, n - 1 do
v = v + data:byte(off + i) * mul
mul = mul * 256
end
return v
end
local function fetchBytesSync(url, timeoutSec)
local ok, req = pcall(function()
return inet.request(url, nil, { ["User-Agent"] = "OCBrowser/2.1 (image)" })
end)
if not ok or not req then return nil, "request failed" end
local deadline = computer.uptime() + (timeoutSec or IMG_FETCH_TIMEOUT)
while true do
local fc, ferr = req.finishConnect()
if fc then break end
if ferr then pcall(req.close); return nil, "connect: " .. tostring(ferr) end
if computer.uptime() > deadline then pcall(req.close); return nil, "connect timeout" end
os.sleep(0.05)
end
local code, _, headers = req.response()
if not code then pcall(req.close); return nil, "no response" end
if code == 301 or code == 302 or code == 303 or code == 307 or code == 308 then
local loc = headers and (headers["location"] or headers["Location"])
if type(loc) == "table" then loc = loc[1] end
pcall(req.close)
if loc and loc ~= "" then return fetchBytesSync(loc, timeoutSec) end
return nil, "redirect no location"
end
if code >= 400 then pcall(req.close); return nil, "HTTP " .. tostring(code) end
local chunks, total = {}, 0
while true do
if computer.uptime() > deadline then pcall(req.close); return nil, "body timeout" end
local chunk, rerr = req.read(8192)
if rerr then pcall(req.close); return nil, "read: " .. tostring(rerr) end
if not chunk then break end
if #chunk > 0 then
chunks[#chunks + 1] = chunk
total = total + #chunk
if total > 1024 * 1024 then pcall(req.close); return nil, "image too big" end
else
os.sleep(0.05)
end
end
pcall(req.close)
return table.concat(chunks)
end
local function parseBMP(data)
if #data < 54 then return nil, "too small" end
if data:sub(1, 2) ~= "BM" then return nil, "not BMP" end
local pixOff = _readU(data, 11, 4) + 1
local w = _readU(data, 19, 4)
local hr = _readU(data, 23, 4)
local h, topDown = hr, false
if h >= 0x80000000 then h = 0x100000000 - h; topDown = true end
local bpp = _readU(data, 29, 2)
local cmp = _readU(data, 31, 4)
if bpp ~= 24 and bpp ~= 32 then return nil, "bpp " .. bpp end
if cmp ~= 0 and not (bpp == 32 and cmp == 3) then return nil, "compression " .. cmp end
if w == 0 or h == 0 or w > 400 or h > 400 then return nil, "size " .. w .. "x" .. h end
local bytesPerPx = bpp / 8
local rowSize = math.floor((bpp * w + 31) / 32) * 4
local function pix(x, y)
local row = topDown and y or (h - 1 - y)
local off = pixOff + row * rowSize + x * bytesPerPx
if off + 2 > #data then return 0, 0, 0 end
local b = data:byte(off)
local g = data:byte(off + 1)
local r = data:byte(off + 2)
return r, g, b
end
return { w = w, h = h, pix = pix }
end
-- ── PNG: DEFLATE inflate + расфильтровка скан-линий ────────────────
local function _inflate(data, startPos)
-- data: строка байтов; startPos: 1-based
local pos = startPos
local bitBuf = 0
local bitCnt = 0
local out = {}
local outLen = 0
local yieldCounter = 0
local function checkYield()
yieldCounter = yieldCounter + 1
if yieldCounter >= 2000 then
yieldCounter = 0
os.sleep(0)
end
end
local function getBit()
if bitCnt == 0 then
if pos > #data then error("inflate: out of data") end
bitBuf = data:byte(pos); pos = pos + 1; bitCnt = 8
end
local b = bitBuf % 2
bitBuf = (bitBuf - b) / 2
bitCnt = bitCnt - 1
return b
end
local function getBits(n)
local v, m = 0, 1
for _ = 1, n do
v = v + getBit() * m
m = m + m
end
return v
end
local function alignByte()
bitCnt = 0; bitBuf = 0
end
local function buildHuffman(lens)
local maxLen = 0
for i = 1, #lens do if lens[i] > maxLen then maxLen = lens[i] end end
local blCount = {}
for i = 0, maxLen do blCount[i] = 0 end
for i = 1, #lens do blCount[lens[i]] = blCount[lens[i]] + 1 end
blCount[0] = 0
local nextCode = {}
local code = 0
for bits = 1, maxLen do
code = (code + (blCount[bits - 1] or 0)) * 2
nextCode[bits] = code
end
local tbl = {}
for sym = 1, #lens do
local l = lens[sym]
if l > 0 then
tbl[l] = tbl[l] or {}
tbl[l][nextCode[l]] = sym - 1
nextCode[l] = nextCode[l] + 1
end
end
return { tbl = tbl, maxLen = maxLen }
end
local function decodeSym(huff)
local code = 0
local tbl = huff.tbl
for len = 1, huff.maxLen do
code = code * 2 + getBit()
local row = tbl[len]
if row then
local s = row[code]
if s ~= nil then return s end
end
end
error("inflate: bad huffman")
end
local lenBase = {3,4,5,6,7,8,9,10,11,13,15,17,19,23,27,31,35,43,51,59,67,83,99,115,131,163,195,227,258}
local lenExtra = {0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0}
local distBase = {1,2,3,4,5,7,9,13,17,25,33,49,65,97,129,193,257,385,513,769,1025,1537,2049,3073,4097,6145,8193,12289,16385,24577}
local distExtra = {0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13}
local fixedLit, fixedDist
local function getFixed()
if fixedLit then return fixedLit, fixedDist end
local ll = {}
for i = 1, 144 do ll[i] = 8 end
for i = 145,256 do ll[i] = 9 end
for i = 257,280 do ll[i] = 7 end
for i = 281,288 do ll[i] = 8 end
local dl = {}
for i = 1, 32 do dl[i] = 5 end
fixedLit = buildHuffman(ll)
fixedDist = buildHuffman(dl)
return fixedLit, fixedDist
end
while true do
local bfinal = getBit()
local btype = getBits(2)
if btype == 0 then
alignByte()
if pos + 3 > #data then error("inflate: stored too short") end
local len = data:byte(pos) + data:byte(pos + 1) * 256
pos = pos + 4
for _ = 1, len do
outLen = outLen + 1
out[outLen] = data:byte(pos)
pos = pos + 1
end
elseif btype == 1 or btype == 2 then
local litH, distH
if btype == 1 then
litH, distH = getFixed()
else
local hlit = getBits(5) + 257
local hdist = getBits(5) + 1
local hclen = getBits(4) + 4
local order = {16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15}
local clens = {}
for i = 1, 19 do clens[i] = 0 end
for i = 1, hclen do clens[order[i] + 1] = getBits(3) end
local clH = buildHuffman(clens)
local lens = {}
local i = 1
local total = hlit + hdist
while i <= total do
local s = decodeSym(clH)
if s < 16 then
lens[i] = s; i = i + 1
elseif s == 16 then
local rep = getBits(2) + 3
local prev = lens[i - 1] or 0
for _ = 1, rep do lens[i] = prev; i = i + 1 end
elseif s == 17 then
local rep = getBits(3) + 3
for _ = 1, rep do lens[i] = 0; i = i + 1 end
else -- 18
local rep = getBits(7) + 11
for _ = 1, rep do lens[i] = 0; i = i + 1 end
end
end
local ll = {}; for k = 1, hlit do ll[k] = lens[k] end
local dl = {}; for k = 1, hdist do dl[k] = lens[hlit + k] end
litH = buildHuffman(ll)
distH = buildHuffman(dl)
end
while true do
checkYield()
local s = decodeSym(litH)
if s < 256 then
outLen = outLen + 1
out[outLen] = s
elseif s == 256 then
break
else
local lc = s - 257 + 1
local len = lenBase[lc] + getBits(lenExtra[lc])
local ds = decodeSym(distH) + 1
local dist = distBase[ds] + getBits(distExtra[ds])
local startIdx = outLen - dist + 1
for k = 0, len - 1 do
outLen = outLen + 1
out[outLen] = out[startIdx + k]
end
end
end
else
error("inflate: btype 3")
end
if bfinal == 1 then break end
end
return out
end
local function parsePNG(data)
if #data < 8 or data:sub(1, 8) ~= "\137PNG\r\n\26\n" then return nil, "not PNG" end
local function readBE(off, n)
local v = 0
for i = 0, n - 1 do v = v * 256 + data:byte(off + i) end
return v
end
local pos = 9
local width, height, bitDepth, colorType
local palette = nil
local idat = {}
while pos <= #data do
if pos + 7 > #data then break end
local len = readBE(pos, 4)
local typ = data:sub(pos + 4, pos + 7)
local dataStart = pos + 8
pos = pos + 8 + len + 4 -- + crc
if typ == "IHDR" then
width = readBE(dataStart, 4)
height = readBE(dataStart + 4, 4)
bitDepth = data:byte(dataStart + 8)
colorType = data:byte(dataStart + 9)
elseif typ == "PLTE" then
palette = {}
for i = 0, math.floor(len / 3) - 1 do
palette[i] = {
data:byte(dataStart + i * 3),
data:byte(dataStart + i * 3 + 1),
data:byte(dataStart + i * 3 + 2),
}
end
elseif typ == "IDAT" then
idat[#idat + 1] = data:sub(dataStart, dataStart + len - 1)
elseif typ == "IEND" then
break
end
end
if not width or not height then return nil, "no IHDR" end
if bitDepth ~= 8 then return nil, "bitdepth " .. bitDepth end
if colorType ~= 0 and colorType ~= 2 and colorType ~= 3
and colorType ~= 4 and colorType ~= 6 then
return nil, "colortype " .. colorType
end
if colorType == 3 and not palette then return nil, "no PLTE" end
local zdata = table.concat(idat)
if #zdata < 3 then return nil, "empty IDAT" end
-- zlib header: 2 bytes; trailer adler32: 4 bytes (игнорируем)
local ok, raw = pcall(_inflate, zdata, 3)
if not ok then return nil, "inflate: " .. tostring(raw) end
local channels = ({[0]=1, [2]=3, [3]=1, [4]=2, [6]=4})[colorType]
local bpp = channels -- байт на пиксель (при 8-bit)
local stride = width * bpp
local rgb = {} -- плоский массив, 3 байта на пиксель (R,G,B)
local prev = {}
for i = 1, stride do prev[i] = 0 end
local idx = 1
for y = 0, height - 1 do
if idx > #raw then return nil, "truncated raw" end
local ftype = raw[idx]; idx = idx + 1
local row = {}
for x = 1, stride do
local r = raw[idx]; idx = idx + 1
if not r then return nil, "truncated row" end
local left = (x > bpp) and row[x - bpp] or 0
local up = prev[x]
local upLeft = (x > bpp) and prev[x - bpp] or 0
local rec
if ftype == 0 then rec = r
elseif ftype == 1 then rec = (r + left) % 256
elseif ftype == 2 then rec = (r + up) % 256
elseif ftype == 3 then rec = (r + math.floor((left + up) / 2)) % 256
elseif ftype == 4 then
local p = left + up - upLeft
local pa = math.abs(p - left)
local pb = math.abs(p - up)
local pc = math.abs(p - upLeft)
local pred
if pa <= pb and pa <= pc then pred = left
elseif pb <= pc then pred = up
else pred = upLeft end
rec = (r + pred) % 256
else
return nil, "filter " .. ftype
end
row[x] = rec
end
-- извлечь RGB из строки
local rowBase = y * width * 3
for x = 0, width - 1 do
local r, g, b
if colorType == 2 then -- RGB
local off = x * 3
r, g, b = row[off + 1], row[off + 2], row[off + 3]
elseif colorType == 6 then -- RGBA
local off = x * 4
r, g, b = row[off + 1], row[off + 2], row[off + 3]
elseif colorType == 0 then -- Gray
local v = row[x + 1]; r, g, b = v, v, v
elseif colorType == 4 then -- GrayA
local v = row[x * 2 + 1]; r, g, b = v, v, v
elseif colorType == 3 then -- Palette
local pi = row[x + 1]
local pe = palette[pi]
if pe then r, g, b = pe[1], pe[2], pe[3]
else r, g, b = 0, 0, 0 end
end
rgb[rowBase + x * 3 + 1] = r
rgb[rowBase + x * 3 + 2] = g
rgb[rowBase + x * 3 + 3] = b
end
prev = row
if y % 8 == 0 then os.sleep(0) end
end
local function pix(x, y)
local off = (y * width + x) * 3 + 1
return rgb[off] or 0, rgb[off + 1] or 0, rgb[off + 2] or 0
end
return { w = width, h = height, pix = pix }
end
-- ── выбор парсера по сигнатуре ─────────────────────────────────
local function decodeImage(data)
if #data < 8 then return nil, "too small" end
if data:sub(1, 2) == "BM" then return parseBMP(data) end
if data:sub(1, 8) == "\137PNG\r\n\26\n" then return parsePNG(data) end
return nil, "unknown format (" .. tostring(data:byte(1)) .. " " .. tostring(data:byte(2)) .. ")"
end
-- ── Кеш изображений: память (LRU отрендеренных lines) + диск (RGB) ──
local IMG_CACHE_DIR = "/home/.cache/ocbrowser/img/"
local IMG_CACHE_BUDGET = 512 * 1024 -- байт на диске; настраивается
local IMG_MEM_CACHE_CAP = 8 -- сколько lines держать в RAM
-- djb2 hash → hex
local function _hash(s)
local h = 5381
for i = 1, #s do
h = ((h * 33) + s:byte(i)) % 2147483647
end
return string.format("%08x", h)
end
local function _cacheKey(src, w, h)
return _hash(src .. "|" .. w .. "x" .. h)
end
local function _cachePath(key)
return IMG_CACHE_DIR .. key .. ".rgb"
end
local function _ensureCacheDir()
if not fs then return false end
if fs.exists(IMG_CACHE_DIR) then return true end
local ok = pcall(fs.makeDirectory, IMG_CACHE_DIR)
return ok and fs.exists(IMG_CACHE_DIR)
end
local function _writeU32BE(f, v)
f:write(string.char(
math.floor(v / 16777216) % 256,
math.floor(v / 65536) % 256,
math.floor(v / 256) % 256,
v % 256))
end
local function _readU32BE(f)
local s = f:read(4)
if not s or #s < 4 then return nil end
return s:byte(1) * 16777216 + s:byte(2) * 65536 + s:byte(3) * 256 + s:byte(4)
end
local function diskCacheLoad(key)
if not fs then return nil end
local path = _cachePath(key)
if not fs.exists(path) then return nil end
local f = io.open(path, "rb")
if not f then return nil end
local w = _readU32BE(f)
local h = _readU32BE(f)
if not w or not h or w == 0 or h == 0 or w > 1000 or h > 1000 then
f:close(); return nil
end
local need = w * h * 3
local data = f:read(need)
f:close()
if not data or #data < need then return nil end
local function pix(x, y)
local off = (y * w + x) * 3 + 1
return data:byte(off) or 0, data:byte(off + 1) or 0, data:byte(off + 2) or 0
end
return { w = w, h = h, pix = pix }
end
local function _diskTotalAndList()
local files, total = {}, 0
if not fs or not fs.exists(IMG_CACHE_DIR) then return files, 0 end
for name in fs.list(IMG_CACHE_DIR) do
if not name:match("/$") then
local p = IMG_CACHE_DIR .. name
local sz = fs.size(p) or 0
local mt = fs.lastModified(p) or 0
files[#files + 1] = { path = p, size = sz, mtime = mt }
total = total + sz
end
end
return files, total
end
local function _evictDisk(reserve)
local files, total = _diskTotalAndList()
local cap = IMG_CACHE_BUDGET - (reserve or 0)
if total <= cap then return end
table.sort(files, function(a, b) return a.mtime < b.mtime end)
local i = 1
while total > cap * 0.75 and i <= #files do
if pcall(fs.remove, files[i].path) then
total = total - files[i].size
end
i = i + 1
end
end
local function diskCacheSave(key, img)
if not fs then return end
if not _ensureCacheDir() then return end
local need = img.w * img.h * 3 + 8
if need > IMG_CACHE_BUDGET then return end -- картинка больше всего бюджета
_evictDisk(need)
local path = _cachePath(key)
local ok, f = pcall(io.open, path, "wb")
if not ok or not f then return end
_writeU32BE(f, img.w)
_writeU32BE(f, img.h)
local chunks, n = {}, 0
for y = 0, img.h - 1 do
for x = 0, img.w - 1 do
local r, g, b = img.pix(x, y)
n = n + 1
chunks[n] = string.char(r or 0, g or 0, b or 0)
end
if n >= 512 then
f:write(table.concat(chunks, "", 1, n))
chunks, n = {}, 0
if y % 16 == 0 then os.sleep(0) end
end
end
if n > 0 then f:write(table.concat(chunks, "", 1, n)) end
f:close()
end
-- ── Mem LRU: ключ → отрендеренные lines (общий с диск-уровнем ключ) ──
local _memCache = { items = {}, order = {} }
local function memCacheGet(key)
local v = _memCache.items[key]
if not v then return nil end
for i, k in ipairs(_memCache.order) do
if k == key then table.remove(_memCache.order, i); break end
end
_memCache.order[#_memCache.order + 1] = key
return v
end
local function memCachePut(key, lines)
if _memCache.items[key] then
for i, k in ipairs(_memCache.order) do
if k == key then table.remove(_memCache.order, i); break end
end
end
_memCache.items[key] = lines
_memCache.order[#_memCache.order + 1] = key
while #_memCache.order > IMG_MEM_CACHE_CAP do
local old = table.remove(_memCache.order, 1)
_memCache.items[old] = nil
end
end
-- глубокая копия списка lines (чтобы splice/перерисовка не портили кеш)
local function _cloneLines(src)
local out = {}
for i, ln in ipairs(src) do
local segs = {}
for j, s in ipairs(ln.segs) do
segs[j] = { text = s.text, fg = s.fg, bg = s.bg, link = s.link }
end
out[i] = { segs = segs }
end
return out
end
local function renderImageToLines(img)
local out = {}
local rows = math.ceil(img.h / 2)
for cy = 0, rows - 1 do
local segs = {}
local lastFg, lastBg = -1, -1
for x = 0, img.w - 1 do
local r1, g1, b1 = img.pix(x, cy * 2)
local fg = r1 * 65536 + g1 * 256 + b1
local bg
if cy * 2 + 1 < img.h then
local r2, g2, b2 = img.pix(x, cy * 2 + 1)
bg = r2 * 65536 + g2 * 256 + b2
else
bg = C.pageBg
end
if fg == lastFg and bg == lastBg and #segs > 0 then
segs[#segs].text = segs[#segs].text .. "▀"
else
segs[#segs + 1] = { text = "▀", fg = fg, bg = bg, link = nil }
lastFg, lastBg = fg, bg
end
end
out[#out + 1] = { segs = segs }
end
return out
end
-- пересчёт firstLine у всех ссылок (вызывается после сплайса)
local function recomputeLinkLines()
for _, lk in ipairs(state.links) do lk.firstLine = nil end
for li, ln in ipairs(state.lines) do
if ln.segs then
for _, seg in ipairs(ln.segs) do
if seg.link then
local lk = state.links[seg.link]
if lk and not lk.firstLine then lk.firstLine = li end
end
end
end
end
end
function processNextImage()
if not state.imgQueue or #state.imgQueue == 0 then return end
local req = table.remove(state.imgQueue, 1)
-- найти текущий индекс плейсхолдера по ссылке на таблицу строки
local L = nil
for i, ln in ipairs(state.lines) do
if ln == req.lineRef then L = i; break end
end
if not L then return end
local prevStatus = state.statusMsg
state.statusMsg = "Изображение: " .. (req.alt ~= "" and req.alt or req.src)
drawBar()
local absSrc = req.src
if not absSrc:match("^https?://") then
absSrc = resolveUrl(state.url, absSrc)
end
local maxW = math.min(IMG_MAX_W_CHARS, PAGE_W - 1)
local maxH = IMG_MAX_H_PIXELS
local key = _cacheKey(absSrc, maxW, maxH)
local newLines
local img
-- 1) mem hit → мгновенно
local cachedLines = memCacheGet(key)
if cachedLines then
newLines = _cloneLines(cachedLines)
else
-- 2) disk hit → декодировать RGB и отрендерить
img = diskCacheLoad(key)
if img then
state.statusMsg = "Изображение [cache]: " .. (req.alt ~= "" and req.alt or req.src)
drawBar()
else
-- 3) miss → перебор прокси
local errors = {}
for _, p in ipairs(IMG_PROXIES) do
state.statusMsg = "Изображение [" .. p.name .. "]: " .. (req.alt ~= "" and req.alt or req.src)
drawBar()
local src = p.encode and urlEncode(absSrc) or absSrc
local url
if p.mode == "resize" then
url = string.format(p.url, src, maxW, maxH)
else
url = string.format(p.url, src)
end
local body, ferr = fetchBytesSync(url, IMG_FETCH_TIMEOUT)
if body then
local decoded, derr = decodeImage(body)
if decoded then
img = decoded
break
else
errors[#errors + 1] = p.name .. ":декод:" .. tostring(derr)
end
else
errors[#errors + 1] = p.name .. ":" .. tostring(ferr)
end
end
if img then
pcall(diskCacheSave, key, img)
else
newLines = { { segs = { { text = "✖ [IMG] " .. (req.alt or "") .. " — " .. table.concat(errors, " | "),
fg = C.error, bg = C.pageBg, link = nil } } } }
end
end
if img and not newLines then
newLines = renderImageToLines(img)
if req.alt and req.alt ~= "" then
newLines[#newLines + 1] = { segs = {
{ text = " " .. req.alt, fg = C.status, bg = C.pageBg, link = nil }
} }
end
-- сохранить в mem-кеш (отдельной копией, см. _cloneLines на чтении)
memCachePut(key, _cloneLines(newLines))
end
end
-- сплайс: удалить плейсхолдер, вставить строки изображения
table.remove(state.lines, L)
for i, ln in ipairs(newLines) do
table.insert(state.lines, L + i - 1, ln)
end
recomputeLinkLines()
state.statusMsg = prevStatus
drawBar()
drawPage()
end
local function setLoading(on)
state.loading = on and true or false
if not on then
state.loadProgress = nil
state.loadReceived = 0
state.loadTotal = 0
end
end
local UA_BROWSER = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
.. "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OCBrowser/2.1"
local function startFetchJob(url, postData, method)
if state.fetchJob and state.fetchJob.req and state.fetchJob.req.close then
pcall(function() state.fetchJob.req.close() end)
end
setLoading(true)
state.statusMsg = "Загрузка: " .. url
state.loadProgress = nil
state.loadReceived = 0
state.loadTotal = 0
state.lines = { { text = "Загрузка...", color = C.status } }
state.links = {}
state.scroll = 0
state.selLink = -1
local headers = {
["User-Agent"] = UA_BROWSER,
["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain,*/*;q=0.8",
["Accept-Language"] = "en-US,en;q=0.9,ru;q=0.8",
}
if postData then
headers["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8"
headers["Content-Length"] = tostring(#postData)
end
state.fetchJob = {
url = url,
phase = "connect",
req = inet.request(url, postData, headers, method or (postData and "POST" or "GET")),
chunks = {},
received = 0,
total = nil,
ct = nil,
code = nil,
err = nil,
redirectCount = 0,
postData = postData,
method = method,
}
drawBar(); drawPage()
end
local function finalizeFetch(job)
state.fetchJob = nil
setLoading(false)
if job.err then
state.statusMsg = "Ошибка: " .. tostring(job.err)
local errHtml = string.format([[
Ошибка загрузки
URL: %s
%s
На главную
]], job.url, tostring(job.err))
renderPage(errHtml, "text/html")
else
local body = table.concat(job.chunks)
state.statusMsg = (job.code and ("HTTP " .. job.code .. " | ") or "") .. job.url
renderPage(body, job.ct)
end
drawBar(); drawPage()
end
local function stepFetchJob()
local job = state.fetchJob
if not job then return end
if not job.req then job.err = job.err or "Нет запроса"; return finalizeFetch(job) end
local ok, stepErr = pcall(function()
if job.phase == "connect" then
if job.req.finishConnect() then
job.phase = "headers"
end
return
end
if job.phase == "headers" then
local respCode, respMsg, headers = job.req.response()
if not respCode then
job.req.close()
error("Пустой ответ")
end
job.code = respCode
if job.code == 301 or job.code == 302 or job.code == 303 or job.code == 307 or job.code == 308 then
local loc = headers and (headers["location"] or headers["Location"])
if loc then
if type(loc) == "table" then loc = loc[1] end
job.req.close()
job.redirectCount = (job.redirectCount or 0) + 1
if job.redirectCount > 10 then error("Слишком много редиректов") end
local newUrl = resolveUrl(job.url, loc)
state.url = newUrl
startFetchJob(newUrl)
return
end
end
if job.code >= 400 then
job.req.close()
error("HTTP " .. job.code .. (respMsg and (" " .. respMsg) or ""))
end
local rawCt = headers and (headers["content-type"] or headers["Content-Type"] or "text/html") or "text/html"
if type(rawCt) == "table" then rawCt = rawCt[1] end
job.ct = rawCt
local cl = headers and (headers["content-length"] or headers["Content-Length"]) or nil
if type(cl) == "table" then cl = cl[1] end
if cl then
local n = tonumber(cl)
if n and n > 0 then job.total = n end
end
job.phase = "body"
return
end
if job.phase == "body" then
local chunk = job.req.read(4096)
if chunk then
job.chunks[#job.chunks + 1] = chunk
job.received = (job.received or 0) + #chunk
state.loadReceived = job.received
state.loadTotal = job.total or 0
if job.total then
state.loadProgress = math.max(0, math.min(1, job.received / job.total))
else
state.loadProgress = nil
end
state.statusMsg = "Загрузка: " .. job.url
drawBar()
else
job.req.close()
job.phase = "done"
finalizeFetch(job)
end
return
end
end)
if not ok then
job.err = tostring(stepErr)
pcall(function() if job.req and job.req.close then job.req.close() end end)
finalizeFetch(job)
end
end
local function fetchUrl(url)
state.loading = true
state.statusMsg = "Подключение к " .. url .. "..."
drawBar()
local ok, resp = pcall(function()
local req = inet.request(url, nil, {
["User-Agent"] = "OCBrowser/2.0 OpenComputers",
["Accept"] = "text/html,text/plain,*/*",
})
-- ждём ответа до 10 сек
local deadline = os.time() + 100 -- 100 тиков ≈ 5 сек
while not req.finishConnect() do
if os.time() > deadline then error("Таймаут соединения") end
os.sleep(0.05)
end
local code, msg, headers = req.response()
if not code then error("Нет ответа от сервера") end
-- обработка редиректов (301,302,303,307,308)
if (code == 301 or code == 302 or code == 303 or code == 307 or code == 308) then
local loc = (headers and (headers["location"] or headers["Location"])) or nil
if loc then
req.close()
if type(loc) == "table" then loc = loc[1] end
return fetchUrl(resolveUrl(url, loc))
end
end
if code >= 400 then
error("HTTP " .. code .. " " .. (msg or ""))
end
-- читаем тело
local body = ""
local chunk
repeat
chunk = req.read(8192)
if chunk then body = body .. chunk end
until not chunk
req.close()
-- определяем Content-Type
local ct = ""
if headers then ct = headers["content-type"] or headers["Content-Type"] or "" end
if type(ct) == "table" then ct = ct[1] end
return body, ct, code
end)
state.loading = false
if not ok then
return nil, tostring(resp)
end
return resp -- body, ct, code через pcall-vararg не идеально, перепишем ниже
end
-- Исправленная версия с явными возвратами
local function doFetch(url)
state.loading = true
state.statusMsg = "Загрузка: " .. url
drawBar()
local body, err, ct, code
local ok, e = pcall(function()
local req = inet.request(url, nil, {
["User-Agent"] = "OCBrowser/2.0 (OpenComputers)",
["Accept"] = "text/html,text/plain,*/*;q=0.9",
})
local deadline = computer and (computer.uptime() + 10) or nil
while not req.finishConnect() do
if deadline and computer.uptime() > deadline then
req.close()
error("Таймаут соединения")
end
os.sleep(0.05)
end
local respCode, respMsg, headers = req.response()
if not respCode then req.close(); error("Пустой ответ") end
code = respCode
-- редирект
if code == 301 or code == 302 or code == 303 or code == 307 or code == 308 then
local loc = headers and (headers["location"] or headers["Location"])
if loc then
if type(loc) == "table" then loc = loc[1] end
req.close()
local rb, re, rct, rc = doFetch(resolveUrl(url, loc))
body = rb; err = re; ct = rct; code = rc
return
end
end
if code >= 400 then
req.close()
error("HTTP " .. code .. (respMsg and (" " .. respMsg) or ""))
end
local rawCt = headers and (headers["content-type"] or headers["Content-Type"] or "text/html") or "text/html"
if type(rawCt) == "table" then rawCt = rawCt[1] end
ct = rawCt
local chunks = {}
local chunk
repeat
chunk = req.read(4096)
if chunk then chunks[#chunks + 1] = chunk end
until not chunk
req.close()
body = table.concat(chunks)
end)
state.loading = false
if not ok then
return nil, tostring(e), nil, nil
end
return body, err, ct, code
end
-- ═══════════════════════════════════════════════════════════════
-- ОБЛАСТЬ СТРАНИЦЫ
-- ═══════════════════════════════════════════════════════════════
-- отрисовка одной строки области страницы (row: 1..PAGE_H)
local function drawRow(row)
local li = state.scroll + row
local y = PAGE_Y + row - 1
local line = state.lines[li]
if not line or not line.segs then
setBg(C.pageBg)
gpu.fill(1, y, PAGE_W, 1, " ")
return
end
local selIdx = state.selLink
local x = 1
local lastFg, lastBg = nil, nil
for _, seg in ipairs(line.segs) do
local fg = seg.fg or C.pageText
local bg = seg.bg or C.pageBg
if selIdx >= 1 and seg.link == selIdx then
bg = C.btnHover
fg = C.btnFg
end
if fg ~= lastFg then setFg(fg); lastFg = fg end
if bg ~= lastBg then setBg(bg); lastBg = bg end
gpu.set(x, y, seg.text)
x = x + unicode.len(seg.text)
if x > PAGE_W then break end
end
if x <= PAGE_W then
if lastBg ~= C.pageBg then setBg(C.pageBg) end
gpu.fill(x, y, PAGE_W - x + 1, 1, " ")
end
end
local function drawScrollbar()
local total = #state.lines
local scroll = state.scroll
setBg(C.scrollbar) setFg(C.scrollFg)
fill(W, PAGE_Y, 1, PAGE_H, "│")
if total > PAGE_H then
local barH = math.max(1, math.floor(PAGE_H * PAGE_H / total))
local barY = math.floor(scroll * (PAGE_H - barH) / math.max(1, total - PAGE_H))
setBg(C.scrollFg)
fill(W, PAGE_Y + barY, 1, barH, "█")
end
end
drawPage = function()
for row = 1, PAGE_H do
drawRow(row)
end
drawScrollbar()
end
-- инкрементальный скролл: сдвиг через gpu.copy + рендер только новых N строк
local function drawPageScroll(oldScroll, newScroll)
local delta = newScroll - oldScroll
if delta == 0 then return end
if math.abs(delta) >= PAGE_H then
drawPage()
return
end
if delta > 0 then
-- контент сдвигается вверх на delta; новые строки внизу
gpu.copy(1, PAGE_Y + delta, PAGE_W, PAGE_H - delta, 0, -delta)
for row = PAGE_H - delta + 1, PAGE_H do drawRow(row) end
else
local n = -delta
gpu.copy(1, PAGE_Y, PAGE_W, PAGE_H - n, 0, n)
for row = 1, n do drawRow(row) end
end
drawScrollbar()
end
-- ═══════════════════════════════════════════════════════════════
-- ЗАГРУЗКА И РЕНДЕР СТРАНИЦЫ
-- ═══════════════════════════════════════════════════════════════
renderPage = function(body, ct)
ct = ct or "text/html"
local isHtml = ct:find("html")
or body:match("^%s*]")
local lines, links, forms, inputs
if isHtml then
lines, links, forms, inputs = parseHTML(body, PAGE_W)
else
lines = {}
links = {}
forms = {}
inputs = {}
local s = body:gsub("\r\n", "\n"):gsub("\r", "\n")
for line in (s .. "\n"):gmatch("([^\n]*)\n") do
local rest = line
if rest == "" then
lines[#lines+1] = { segs = {} }
else
while unicode.len(rest) > PAGE_W do
lines[#lines+1] = { segs = { { text = unicode.sub(rest, 1, PAGE_W), fg = C.pageText, bg = C.pageBg } } }
rest = unicode.sub(rest, PAGE_W + 1)
end
lines[#lines+1] = { segs = { { text = rest, fg = C.pageText, bg = C.pageBg } } }
end
end
end
state.lines = lines
state.links = links
state.forms = forms or {}
state.inputs = inputs or {}
state.scroll = 0
state.selLink = -1
state.inputEdit = nil
-- Авто-детект «JS-only» страницы: если HTML был большой но видимого текста почти нет —
-- подсказать пользователю про Reader Mode (Ctrl+R).
if isHtml and not state.url:find("r.jina.ai", 1, true) then
local visibleChars = 0
for _, ln in ipairs(lines) do
for _, seg in ipairs(ln.segs or {}) do
local t = seg.text or ""
if not t:match("^%s*$") then
visibleChars = visibleChars + unicode.len(t)
end
end
if visibleChars > 200 then break end
end
if visibleChars < 100 and #body > 800 then
state.statusMsg = "Страница почти пустая (JS-only?). Нажмите Ctrl+R — Reader Mode"
end
end
-- собрать очередь изображений (плейсхолдеры помечены ._imgReq)
state.imgQueue = {}
for _, ln in ipairs(state.lines) do
if ln._imgReq then
state.imgQueue[#state.imgQueue + 1] = {
lineRef = ln, src = ln._imgReq.src, alt = ln._imgReq.alt
}
ln._imgReq = nil
end
end
end
local function _formUrlEncode(s)
s = tostring(s or "")
return (s:gsub("([^%w%-_.~])", function(c)
if c == " " then return "+" end
return string.format("%%%02X", c:byte())
end))
end
local function buildFormQuery(formIdx)
local f = state.forms and state.forms[formIdx]
if not f then return "" end
local parts = {}
for _, ii in ipairs(f.inputs) do
local rec = state.inputs[ii]
if rec and rec.name and rec.name ~= "" then
if rec.type == "checkbox" or rec.type == "radio" then
if rec.checked then
parts[#parts + 1] = _formUrlEncode(rec.name) .. "=" .. _formUrlEncode(rec.value or "on")
end
elseif rec.type == "submit" or rec.type == "image" or rec.type == "button"
or rec.type == "reset" then
-- submit-кнопка отправляется только если это та, на которую нажали (учтено вызывающей стороной)
else
parts[#parts + 1] = _formUrlEncode(rec.name) .. "=" .. _formUrlEncode(rec.value or "")
end
end
end
return table.concat(parts, "&")
end
-- Reader Mode: перезапрашивает текущий URL через r.jina.ai (JS-render на их сервере → Markdown)
local openInReader
local function navigate(url, pushHistory, postData)
if url == "" then return end
-- нормализация
if not url:match("^about:") and not url:match("^https?://") then
url = "http://" .. url
end
if url == "about:home" then
state.url = url
state.statusMsg = "Домашняя страница"
renderPage(HOME_PAGE, "text/html")
if state.fetchJob then
if state.fetchJob.req and state.fetchJob.req.close then pcall(function() state.fetchJob.req.close() end) end
state.fetchJob = nil
setLoading(false)
end
if pushHistory ~= false then
if state.histPos < #state.history then
for i = state.histPos + 1, #state.history do state.history[i] = nil end
end
state.history[#state.history + 1] = url
state.histPos = #state.history
end
drawBar(); drawPage()
return
end
if pushHistory ~= false then
if state.histPos < #state.history then
for i = state.histPos + 1, #state.history do state.history[i] = nil end
end
state.history[#state.history + 1] = url
state.histPos = #state.history
end
state.url = url
startFetchJob(url, postData, postData and "POST" or "GET")
end
openInReader = function(targetUrl)
targetUrl = targetUrl or state.url
if not targetUrl or targetUrl == "" or targetUrl:match("^about:") then
state.statusMsg = "Reader Mode: нет URL для загрузки"
drawBar()
return
end
if targetUrl:find("r.jina.ai", 1, true) then
state.statusMsg = "Уже в Reader Mode"
drawBar()
return
end
navigate("https://r.jina.ai/" .. targetUrl)
end
-- Сабмит формы по индексу submit-кнопки внутри inputs[]
local function submitForm(submitIdx)
local rec = state.inputs[submitIdx]
if not rec or not rec.formIdx then
state.statusMsg = "Кнопка вне формы"
drawBar()
return
end
local f = state.forms[rec.formIdx]
if not f then return end
local query = buildFormQuery(rec.formIdx)
-- если у submit-кнопки есть name= — добавить её
if rec.name and rec.name ~= "" then
local extra = _formUrlEncode(rec.name) .. "=" .. _formUrlEncode(rec.value or "")
query = (query ~= "" and (query .. "&") or "") .. extra
end
local action = f.action ~= "" and f.action or state.url
action = resolveUrl(state.url, action)
if f.method == "post" then
navigate(action, true, query)
else
-- GET: добавить query к URL
if query ~= "" then
local sep = action:find("?", 1, true) and "&" or "?"
action = action .. sep .. query
end
navigate(action)
end
end
-- Перерисовать строку с inputом после изменения значения
local function refreshInputDisplay(idx)
local rec = state.inputs[idx]
if not rec or not rec._segRef or not rec._lineRef then return end
rec._segRef.text = _renderInputText(rec)
-- найти номер строки
for li, ln in ipairs(state.lines) do
if ln == rec._lineRef then
local row = li - state.scroll
if row >= 1 and row <= PAGE_H then drawRow(row) end
break
end
end
end
-- ═══════════════════════════════════════════════════════════════
-- НАВИГАЦИЯ СКРОЛЛ / ССЫЛКИ
-- ═══════════════════════════════════════════════════════════════
local function scrollBy(n)
local oldScroll = state.scroll
local maxScroll = math.max(0, #state.lines - PAGE_H)
local newScroll = math.max(0, math.min(maxScroll, oldScroll + n))
if newScroll == oldScroll then return end
state.scroll = newScroll
drawPageScroll(oldScroll, newScroll)
end
local function goBack()
if state.histPos > 1 then
state.histPos = state.histPos - 1
navigate(state.history[state.histPos], false)
end
end
local function goForward()
if state.histPos < #state.history then
state.histPos = state.histPos + 1
navigate(state.history[state.histPos], false)
end
end
local function selectNextLink(dir)
dir = dir or 1
if #state.links == 0 then return end
state.selLink = state.selLink + dir
if state.selLink < 1 then state.selLink = #state.links end
if state.selLink > #state.links then state.selLink = 1 end
local lk = state.links[state.selLink]
local li = lk and lk.firstLine or 1
if li - state.scroll < 1 then state.scroll = li - 1 end
if li - state.scroll > PAGE_H then state.scroll = li - PAGE_H end
state.scroll = math.max(0, state.scroll)
drawPage()
end
local function startInputEdit(idx)
local rec = state.inputs[idx]
if not rec then return end
state.inputEdit = {
idx = idx,
buf = rec.value or "",
cursor = unicode.len(rec.value or ""),
}
state.urlFocus = false
drawBar()
end
-- разбор синтетических ssylok form: __input:N / __submit:N / __reset:N / __button:N
local function dispatchLink(href)
if href == "" then return false end
local kind, idx = href:match("^__(%w+):(%d+)$")
if kind and idx then
idx = tonumber(idx)
if kind == "input" then
startInputEdit(idx)
return true
elseif kind == "submit" then
submitForm(idx)
return true
elseif kind == "reset" then
local rec = state.inputs[idx]
if rec and rec.formIdx then
local f = state.forms[rec.formIdx]
for _, ii in ipairs(f.inputs) do
local r = state.inputs[ii]
if r and r.type ~= "submit" and r.type ~= "button" and r.type ~= "reset" then
r.value = ""
refreshInputDisplay(ii)
end
end
end
return true
elseif kind == "button" then
state.statusMsg = " без type=submit — нет JS-обработчика"
drawBar()
return true
end
end
return false
end
local function openSelectedLink()
if state.selLink >= 1 and state.links[state.selLink] then
local href = state.links[state.selLink].href or ""
if href == "" then return end
if dispatchLink(href) then return end
navigate(resolveUrl(state.url, href))
end
end
-- ═══════════════════════════════════════════════════════════════
-- ОБРАБОТКА КЛИКОВ
-- ═══════════════════════════════════════════════════════════════
local function handleClick(x, y, btn)
-- кнопки навигации
if y == 2 then
if x >= BTN[1].x and x < BTN[1].x + 3 then goBack()
elseif x >= BTN[2].x and x < BTN[2].x + 3 then goForward()
elseif x >= BTN[3].x and x < BTN[3].x + 3 then navigate(state.url, false)
elseif x >= BTN[4].x and x < BTN[4].x + 3 then navigate("about:home")
elseif x >= URL_X and x < URL_X + URL_W then
state.urlFocus = true
state.urlBar = state.url
state.urlCursor = unicode.len(state.urlBar)
drawBar()
elseif x >= GO_X then
if state.urlFocus then
navigate(state.urlBar)
state.urlFocus = false
end
else
state.urlFocus = false
drawBar()
end
return
end
-- область страницы — переход по ссылке
if y >= PAGE_Y and y < PAGE_Y + PAGE_H then
local row = y - PAGE_Y + 1
local li = state.scroll + row
local line = state.lines[li]
if line and line.segs then
local col = 1
for _, seg in ipairs(line.segs) do
local sw = unicode.len(seg.text)
if x >= col and x < col + sw then
if seg.link then
local lk = state.links[seg.link]
if lk and lk.href and lk.href ~= "" then
state.selLink = seg.link
if dispatchLink(lk.href) then return end
navigate(resolveUrl(state.url, lk.href))
return
end
end
break
end
col = col + sw
end
end
end
end
-- ═══════════════════════════════════════════════════════════════
-- ОБРАБОТКА КЛАВИАТУРЫ
-- ═══════════════════════════════════════════════════════════════
local function handleKey(keycode, char, player)
-- Режим редактирования инпута формы
if state.inputEdit then
local ie = state.inputEdit
if keycode == keyboard.keys.enter then
local rec = state.inputs[ie.idx]
if rec then
rec.value = ie.buf
refreshInputDisplay(ie.idx)
end
state.inputEdit = nil
drawBar()
-- авто-сабмит: ровно одно текстовое поле + одна submit-кнопка (паттерн поиска)
if rec and rec.formIdx then
local f = state.forms[rec.formIdx]
local submitIdx, nSubmit, nText = nil, 0, 0
for _, ii in ipairs(f.inputs) do
local r = state.inputs[ii]
if r then
if r.type == "submit" or r.type == "image" then
submitIdx = ii; nSubmit = nSubmit + 1
elseif r.type ~= "hidden" and r.type ~= "button" and r.type ~= "reset"
and r.type ~= "checkbox" and r.type ~= "radio" then
nText = nText + 1
end
end
end
if nSubmit == 1 and nText == 1 then submitForm(submitIdx) end
end
return
elseif keycode == keyboard.keys.escape then
state.inputEdit = nil; drawBar(); return
elseif keycode == keyboard.keys.back then
if ie.cursor > 0 then
ie.buf = unicode.sub(ie.buf, 1, ie.cursor - 1) .. unicode.sub(ie.buf, ie.cursor + 1)
ie.cursor = ie.cursor - 1
drawBar()
end
return
elseif keycode == keyboard.keys.delete then
ie.buf = unicode.sub(ie.buf, 1, ie.cursor) .. unicode.sub(ie.buf, ie.cursor + 2)
drawBar(); return
elseif keycode == keyboard.keys.left then
ie.cursor = math.max(0, ie.cursor - 1); drawBar(); return
elseif keycode == keyboard.keys.right then
ie.cursor = math.min(unicode.len(ie.buf), ie.cursor + 1); drawBar(); return
elseif keycode == keyboard.keys.home then
ie.cursor = 0; drawBar(); return
elseif keycode == keyboard.keys["end"] then
ie.cursor = unicode.len(ie.buf); drawBar(); return
elseif char and char ~= 0 and not keyboard.isControl(char) then
local c = unicode.char(char)
ie.buf = unicode.sub(ie.buf, 1, ie.cursor) .. c .. unicode.sub(ie.buf, ie.cursor + 1)
ie.cursor = ie.cursor + 1
drawBar(); return
end
return
end
-- URL строка активна
if state.urlFocus then
if keycode == keyboard.keys.enter then
local url = state.urlBar:match("^%s*(.-)%s*$")
state.urlFocus = false
navigate(url)
elseif keycode == keyboard.keys.escape then
state.urlFocus = false
drawBar()
elseif keycode == keyboard.keys.back then
if state.urlCursor > 0 then
state.urlBar = unicode.sub(state.urlBar, 1, state.urlCursor - 1)
.. unicode.sub(state.urlBar, state.urlCursor + 1)
state.urlCursor = state.urlCursor - 1
drawBar()
end
elseif keycode == keyboard.keys.delete then
state.urlBar = unicode.sub(state.urlBar, 1, state.urlCursor)
.. unicode.sub(state.urlBar, state.urlCursor + 2)
drawBar()
elseif keycode == keyboard.keys.left then
state.urlCursor = math.max(0, state.urlCursor - 1)
drawBar()
elseif keycode == keyboard.keys.right then
state.urlCursor = math.min(unicode.len(state.urlBar), state.urlCursor + 1)
drawBar()
elseif keycode == keyboard.keys.home then
state.urlCursor = 0; drawBar()
elseif keycode == keyboard.keys["end"] then
state.urlCursor = unicode.len(state.urlBar); drawBar()
elseif char and char ~= 0 and not keyboard.isControl(char) then
local c = unicode.char(char)
state.urlBar = unicode.sub(state.urlBar, 1, state.urlCursor) .. c
.. unicode.sub(state.urlBar, state.urlCursor + 1)
state.urlCursor = state.urlCursor + 1
drawBar()
end
return
end
-- глобальные горячие клавиши
local ctrl = keyboard.isControlDown and keyboard.isControlDown()
local alt = keyboard.isAltDown and keyboard.isAltDown()
if keycode == keyboard.keys.f5 then
navigate(state.url, false)
elseif keycode == keyboard.keys.r and ctrl then
openInReader(state.url)
elseif keycode == keyboard.keys.l and ctrl then
state.urlFocus = true
state.urlBar = state.url
state.urlCursor = unicode.len(state.urlBar)
drawBar()
elseif keycode == keyboard.keys.up then
scrollBy(-1)
elseif keycode == keyboard.keys.down then
scrollBy(1)
elseif keycode == keyboard.keys.pageUp then
scrollBy(-PAGE_H)
elseif keycode == keyboard.keys.pageDown then
scrollBy(PAGE_H)
elseif keycode == keyboard.keys.home then
state.scroll = 0; drawPage()
elseif keycode == keyboard.keys["end"] then
state.scroll = math.max(0, #state.lines - PAGE_H); drawPage()
elseif keycode == keyboard.keys.tab then
if keyboard.isShiftDown and keyboard.isShiftDown() then
selectNextLink(-1)
else
selectNextLink(1)
end
elseif keycode == keyboard.keys.enter then
openSelectedLink()
elseif keycode == keyboard.keys.left and alt then
goBack()
elseif keycode == keyboard.keys.right and alt then
goForward()
end
end
-- ═══════════════════════════════════════════════════════════════
-- ГЛАВНЫЙ ЦИКЛ
-- ═══════════════════════════════════════════════════════════════
local function main()
-- сохраняем оригинальные цвета
local origFg = gpu.getForeground()
local origBg = gpu.getBackground()
local origW, origH = gpu.getResolution()
-- очищаем экран
setBg(C.bg) setFg(C.pageText)
gpu.fill(1, 1, W, H, " ")
-- начальная страница
navigate("about:home")
-- цикл событий
while true do
local ev = { event.pull(0.1) }
local etype = ev[1]
if etype == "key_down" then
-- ev: type, addr, char, keycode, player
handleKey(ev[4], ev[3] or 0, ev[5])
elseif etype == "touch" then
-- ev: type, addr, x, y, btn, player
handleClick(ev[3], ev[4], ev[5])
elseif etype == "scroll" then
-- ev: type, addr, x, y, dir, player
scrollBy(ev[5] > 0 and -3 or 3)
elseif etype == "interrupted" then
break
end
if state.fetchJob then
stepFetchJob()
elseif state.imgQueue and #state.imgQueue > 0 then
processNextImage()
end
end
-- восстанавливаем терминал
setBg(origBg) setFg(origFg)
gpu.fill(1, 1, W, H, " ")
gpu.setResolution(origW, origH)
term.setCursor(1, 1)
print("OCBrowser завершён.")
end
-- запуск
local ok, err = pcall(main)
if not ok then
io.stderr:write("Критическая ошибка: " .. tostring(err) .. "\n")
end