Files
neon e28f45452d fix(koreader): keep each device's layout at every interface size
Layout breakpoints use the screen width at 1x, so Interface size scales text,
icons and spacing without dropping Home columns or detail panes. Every page,
sheet and widget picks a layout that fits instead of cutting text or leaving
gaps; search becomes balanced list-row sections, Edit Home keeps hidden widgets,
stock KOReader messages become BookOrbit toasts, and the plugin is 2.1.0.
2026-10-24 03:46:05 +00:00

213 lines
7.1 KiB
Lua

--[[--
Shared geometry for the widget dashboard.
Every size on the board is given in density-independent units (dp) and
converted here, so the board scales like the rest of KOReader
(Screen:scaleBySize, short side / 600) plus one density step: a 10" panel
shows more content instead of the 6" page blown up, and a phone gets
finger-sized type. The reader's interface-size preference is applied after
that device adjustment. Font sizes go through the same scale, so text and
boxes stay in proportion.
Line heights mirror the widgets that draw them: a single-line TextWidget is
ceil(face height) tall, where Noto's face height is 1.362 em, and a
TextBoxWidget line is round(1.3 em).
]]
local Blitbuffer = require("ffi/blitbuffer")
local Device = require("device")
local Font = require("ui/font")
local Screen = Device.screen
local BoardMetrics = {}
BoardMetrics.UI_SCALES = { 1, 1.05, 1.1, 1.15, 1.2 }
BoardMetrics.DEFAULT_UI_SCALE = 1.05
local FACE_HEIGHT_EM = 1.362
local FACES = {
sans = "NotoSans-Regular.ttf",
sans_italic = "NotoSans-Italic.ttf",
serif = "NotoSerif-Regular.ttf",
serif_italic = "NotoSerif-Italic.ttf",
}
-- The sixteen gray levels e-ink panels can show, by name.
BoardMetrics.INK = {
black = Blitbuffer.COLOR_BLACK,
g1 = Blitbuffer.COLOR_GRAY_1,
g2 = Blitbuffer.COLOR_GRAY_2,
g3 = Blitbuffer.COLOR_GRAY_3,
g4 = Blitbuffer.COLOR_GRAY_4,
g5 = Blitbuffer.COLOR_GRAY_5,
g6 = Blitbuffer.COLOR_GRAY_6,
g7 = Blitbuffer.COLOR_GRAY_7,
g8 = Blitbuffer.COLOR_DARK_GRAY,
g9 = Blitbuffer.COLOR_GRAY_9,
ga = Blitbuffer.COLOR_GRAY,
gb = Blitbuffer.COLOR_GRAY_B,
gc = Blitbuffer.COLOR_LIGHT_GRAY,
gd = Blitbuffer.COLOR_GRAY_D,
ge = Blitbuffer.COLOR_GRAY_E,
white = Blitbuffer.COLOR_WHITE,
}
-- Hex values of the same grays, for the SVG icons drawn in them.
BoardMetrics.INK_HEX = {
black = "#000000", g1 = "#111111", g2 = "#222222", g3 = "#333333", g4 = "#444444",
g5 = "#555555", g6 = "#666666", g7 = "#777777", g8 = "#888888", g9 = "#999999",
ga = "#aaaaaa", gb = "#bbbbbb", gc = "#cccccc", gd = "#dddddd", ge = "#eeeeee", white = "#ffffff",
}
local cache = nil
local user_scale = BoardMetrics.DEFAULT_UI_SCALE
local function clamp(v, lo, hi)
if v < lo then return lo end
if v > hi then return hi end
return v
end
function BoardMetrics.normalizeUserScale(value)
local numeric = tonumber(value)
if not numeric then return BoardMetrics.DEFAULT_UI_SCALE end
local rounded = math.floor(numeric * 100 + 0.5) / 100
if rounded == 1.25 then return 1.2 end
for _, scale in ipairs(BoardMetrics.UI_SCALES) do
if rounded == scale then return scale end
end
return BoardMetrics.DEFAULT_UI_SCALE
end
function BoardMetrics.setUserScale(value)
user_scale = BoardMetrics.normalizeUserScale(value)
cache = nil
return user_scale
end
-- The density step: sqrt of how much shorter or longer the panel's short side
-- is than a 6" one (3.6"), kept gentle and within [0.78, 1.12].
function BoardMetrics.densityAdjust(short_px, dpi)
if not short_px or not dpi or dpi <= 0 then return 1 end
local inches = short_px / dpi
local adj = clamp(math.sqrt(3.6 / inches), 0.78, 1.12)
return math.floor(adj * 100 + 0.5) / 100
end
-- Recomputed when the screen geometry changes (rotation, DPI override).
function BoardMetrics.refresh()
local w, h = Screen:getWidth(), Screen:getHeight()
local short = math.min(w, h)
local adj = BoardMetrics.densityAdjust(short, Screen:getDPI())
-- Screen:scaleBySize's own scale: the short side over 600, averaged with
-- DPI / 160 under a user DPI override. Computed here rather than read
-- back from scaleBySize(1e6), which rounds up at the sixth decimal and
-- turns an exact 600 dp screen into 599.9994.
local size_scale = short / 600
local dpi_scale = Screen.dpi_override and (Screen:getDPI() / 160) or size_scale
local base = (size_scale + dpi_scale) / 2
cache = {
w = w,
h = h,
adj = adj,
scale = user_scale,
dp = base * adj * user_scale,
layout_dp = base * adj,
}
cache.W = w / cache.dp
cache.H = h / cache.dp
-- The screen at interface size 1. Layout choices (columns, margins, one
-- or two column pages) are made on these, so a larger interface size
-- draws the device's own layout bigger instead of a smaller device's.
cache.LW = w / cache.layout_dp
cache.LH = h / cache.layout_dp
return cache
end
-- The screen width layout choices are made on, rounded so an exact 600 dp
-- screen is not 599.9994.
function BoardMetrics.layoutW()
return math.floor(BoardMetrics.get().LW * 1000 + 0.5) / 1000
end
-- A pixel width in layout dp, for comparing a box against a layout
-- breakpoint.
function BoardMetrics.layoutDp(w_px)
return w_px / BoardMetrics.get().layout_dp
end
-- dp to pixels at interface size 1, for layout limits that must not move
-- with the interface size.
function BoardMetrics.lpx(n)
if not n or n <= 0 then return 0 end
return math.ceil(n * BoardMetrics.get().layout_dp - 1e-7)
end
function BoardMetrics.get()
if not cache or cache.w ~= Screen:getWidth() or cache.h ~= Screen:getHeight() then
BoardMetrics.refresh()
end
return cache
end
-- dp to device pixels. Rounded up so a positive size never collapses to 0.
function BoardMetrics.px(n)
if not n or n <= 0 then return 0 end
return math.ceil(n * BoardMetrics.get().dp - 1e-7)
end
-- The pixel size of a font of nominal size n.
function BoardMetrics.fs(n)
return BoardMetrics.px(n)
end
-- KOReader applies Screen:scaleBySize inside Font:getFace, so named and file
-- faces receive only BookOrbit's density and interface-size multipliers.
function BoardMetrics.fontSize(n)
local metrics = BoardMetrics.get()
return n * metrics.adj * metrics.scale
end
function BoardMetrics.namedFace(name, n)
return Font:getFace(name, BoardMetrics.fontSize(n))
end
function BoardMetrics.fileFace(file, n)
return Font:getFace(file, BoardMetrics.fontSize(n))
end
-- Single-line text height (TextWidget) for nominal size n.
function BoardMetrics.lh1(n)
return math.ceil(FACE_HEIGHT_EM * BoardMetrics.fs(n) - 1e-7)
end
-- Line height of a TextBoxWidget for nominal size n.
function BoardMetrics.lh(n)
return math.floor(1.3 * BoardMetrics.fs(n) + 0.5)
end
-- Height of an n-line TextBoxWidget of nominal size: lines of round(1.3 em)
-- plus the amount Noto's 1.362 em glyph box overflows one line, which
-- TextBoxWidget adds below the last line. One line is a TextWidget instead.
function BoardMetrics.textBoxHeight(lines, n)
if lines <= 1 then return BoardMetrics.lh1(n) end
local line = BoardMetrics.lh(n)
local face = FACE_HEIGHT_EM * BoardMetrics.fs(n)
return lines * line + math.max(0, -math.floor(line - face))
end
-- A face at nominal size n. Font:getFace applies scaleBySize itself, so it is
-- given the size with only the density step applied.
function BoardMetrics.face(n, kind)
local file = FACES[kind or "sans"] or FACES.sans
return BoardMetrics.fileFace(file, n)
end
-- Rebuilds without a screen, for specs.
function BoardMetrics._setForTest(values)
cache = values
end
return BoardMetrics