Skip to content

Instantly share code, notes, and snippets.

@mallomar
Last active December 1, 2025 16:06
Show Gist options
  • Select an option

  • Save mallomar/1f91df2c1d4f46a30aecae5ae2374d8c to your computer and use it in GitHub Desktop.

Select an option

Save mallomar/1f91df2c1d4f46a30aecae5ae2374d8c to your computer and use it in GitHub Desktop.
--[[
This user patch adds a "header" into the reader display, similar to the footer at the bottom.
Modified to show: Time & Battery (left) | Author — Title (center) | R: percentage | pages (right)
Features:
- Configurable sections (easily turn on/off what appears in each area)
- Dynamic separator matching footer settings
- Battery charging indicator and icon toggle
- Smart space calculation with minimum spacing
- Auto-refresh to keep time/battery current
- Refactored code with minimal duplication
]]--
local TextWidget = require("ui/widget/textwidget")
local CenterContainer = require("ui/widget/container/centercontainer")
local VerticalGroup = require("ui/widget/verticalgroup")
local VerticalSpan = require("ui/widget/verticalspan")
local HorizontalGroup = require("ui/widget/horizontalgroup")
local HorizontalSpan = require("ui/widget/horizontalspan")
local UIManager = require("ui/uimanager")
local BD = require("ui/bidi")
local Size = require("ui/size")
local Geom = require("ui/geometry")
local Device = require("device")
local Font = require("ui/font")
local logger = require("logger")
local util = require("util")
local datetime = require("datetime")
local Screen = Device.screen
local _ = require("gettext")
local T = require("ffi/util").template
local ReaderUI = require("apps/reader/readerui")
local ReaderView = require("apps/reader/modules/readerview")
local _ReaderView_paintTo_orig = ReaderView.paintTo
local _ReaderUI_init_orig = ReaderUI.init
local header_settings = G_reader_settings:readSetting("footer")
local screen_width = Screen:getWidth()
-- Configure what appears in each section (set to true/false as desired)
local left_config = {
time = true,
battery = true,
}
local center_config = {
book_author = true,
book_title = true,
}
local right_config = {
page_progress = true,
percentage = true,
}
-- Configure formatting options for header here, if desired
local header_font_face = "ffont"
local header_font_size = header_settings.text_font_size or 14
local header_font_bold = header_settings.text_font_bold or false
local header_top_padding = Size.padding.small
local header_use_book_margins = true
local header_margin = Size.padding.large
local min_section_spacing = 5
local header_refresh_interval = 60 -- Refresh header every 60 seconds
-- Store the ReaderUI instance for refresh access
local current_reader_ui = nil
local dimen_refresh = nil
-- Helper function: Generate separator from footer settings
local function genSeparator()
local strings = {
bar = " | ",
bullet = " • ",
dot = " · ",
em_dash = " — ",
en_dash = " – ",
}
return strings[header_settings.items_separator] or " | "
end
-- Helper function: Get battery string
local function getBatteryString()
local battery_string = ""
local powerd = Device.powerd
if Device:hasBattery() then
if powerd and powerd.getCapacity then
local batt_lvl = powerd:getCapacity()
if batt_lvl and batt_lvl >= 0 then
local show_battery_icon = header_settings.show_battery_icon or false
if show_battery_icon and powerd.getBatterySymbol then
local batt_symbol = powerd:getBatterySymbol(powerd:isCharged(), powerd:isCharging(), batt_lvl)
battery_string = string.format("%s%d%%", batt_symbol, batt_lvl)
else
battery_string = string.format("B: %d%%", batt_lvl)
if powerd.isCharging and powerd:isCharging() then
battery_string = "+" .. battery_string
end
end
end
end
end
return battery_string
end
-- Helper function: Get text width
local function getTextWidth(text, font_face, font_size, font_bold)
if text == nil or text == "" then
return 0
end
local text_widget = TextWidget:new{
text = text:gsub(" ", "\u{00A0}"),
face = Font:getFace(font_face, font_size),
bold = font_bold,
padding = 0,
}
local width = text_widget:getSize().w
text_widget:free()
return width
end
-- Helper function: Get fitted text
local function getFittedText(text, max_width, font_face, font_size, font_bold)
if text == nil or text == "" then
return ""
end
local text_widget = TextWidget:new{
text = text:gsub(" ", "\u{00A0}"),
max_width = max_width,
face = Font:getFace(font_face, font_size),
bold = font_bold,
padding = 0,
}
local fitted_text, add_ellipsis = text_widget:getFittedText()
text_widget:free()
if add_ellipsis then
fitted_text = fitted_text .. "…"
end
return BD.auto(fitted_text)
end
-- Helper function: Create text widget
local function createTextWidget(text, font_face, font_size, font_bold)
return TextWidget:new {
text = BD.auto(text),
face = Font:getFace(font_face, font_size),
bold = font_bold,
padding = 0,
}
end
-- Helper function: Build header text for a section
local function getHeaderText(config, data, separator)
local parts = {}
if config.time and data.time then
table.insert(parts, data.time)
end
if config.battery and data.battery ~= "" then
table.insert(parts, data.battery)
end
if config.book_author and data.book_author ~= "" then
table.insert(parts, data.book_author)
end
if config.book_title and data.book_title ~= "" then
table.insert(parts, data.book_title)
end
if config.percentage and data.percentage then
table.insert(parts, string.format("R: %.1f%%", data.percentage))
end
if config.page_progress and data.page_progress then
table.insert(parts, data.page_progress)
end
return table.concat(parts, separator)
end
-- Main paint function
ReaderView.paintTo = function(self, bb, x, y)
_ReaderView_paintTo_orig(self, bb, x, y)
if self.render_mode ~= nil then return end
local separator = genSeparator()
local center_separator = " — "
-- Gather all data
local pageno = self.state.page or 1
local pages = self.ui.doc_settings.data.doc_pages or 1
local data = {
time = datetime.secondsToHour(os.time(), G_reader_settings:isTrue("twelve_hour_clock")),
battery = getBatteryString(),
book_author = self.ui.doc_props.authors or "",
book_title = self.ui.doc_props.display_title or "",
percentage = (pageno / pages) * 100,
page_progress = ("%d/%d"):format(pageno, pages),
}
-- Handle multiple authors
if data.book_author and data.book_author:find("\n") then
data.book_author = T(_("%1 et al."), util.splitToArray(data.book_author, "\n")[1] .. ",")
end
-- Build header sections
local left_header = getHeaderText(left_config, data, separator)
local right_header = getHeaderText(right_config, data, separator)
-- Build center with em-dash separator
local center_parts = {}
if center_config.book_author and data.book_author ~= "" then
table.insert(center_parts, data.book_author)
end
if center_config.book_title and data.book_title ~= "" then
table.insert(center_parts, data.book_title)
end
local center_header = table.concat(center_parts, center_separator)
-- Calculate margins
local left_margin = header_margin
local right_margin = header_margin
if header_use_book_margins then
left_margin = self.document:getPageMargins().left or header_margin
right_margin = self.document:getPageMargins().right or header_margin
end
local avail_width = screen_width - left_margin - right_margin
-- Calculate widths and fit center text
local left_width = getTextWidth(left_header, header_font_face, header_font_size, header_font_bold)
local right_width = getTextWidth(right_header, header_font_face, header_font_size, header_font_bold)
local center_available_width = avail_width - left_width - right_width - (min_section_spacing * 2)
local center_header_text = getFittedText(center_header, math.max(100, center_available_width),
header_font_face, header_font_size, header_font_bold)
-- Create text widgets
local left_widget = createTextWidget(left_header, header_font_face, header_font_size, header_font_bold)
local center_widget = createTextWidget(center_header_text, header_font_face, header_font_size, header_font_bold)
local right_widget = createTextWidget(right_header, header_font_face, header_font_size, header_font_bold)
-- Calculate final spacing
local total_text_width = left_widget:getSize().w + center_widget:getSize().w + right_widget:getSize().w
local remaining_space = avail_width - total_text_width
local space_between = math.max(min_section_spacing, remaining_space / 2)
-- Store dimensions for precise refresh area
dimen_refresh = Geom:new{
w = screen_width,
h = math.max(left_widget:getSize().h, center_widget:getSize().h, right_widget:getSize().h) + header_top_padding
}
-- Build and paint header
local header = CenterContainer:new {
dimen = dimen_refresh,
VerticalGroup:new {
VerticalSpan:new { width = header_top_padding },
HorizontalGroup:new {
HorizontalSpan:new { width = left_margin },
left_widget,
HorizontalSpan:new { width = space_between },
center_widget,
HorizontalSpan:new { width = space_between },
right_widget,
HorizontalSpan:new { width = right_margin },
},
},
}
header:paintTo(bb, x, y)
end
-- Auto-refresh functionality
ReaderUI.init = function(self, ...)
_ReaderUI_init_orig(self, ...)
current_reader_ui = self
-- Create a recurring task to force header refresh
local function scheduleHeaderRefresh()
-- Calculate seconds until next minute (sync to clock)
local seconds = 61 - tonumber(os.date("%S"))
UIManager:scheduleIn(seconds, function()
-- Check if auto-refresh is enabled in footer settings
local auto_refresh = header_settings.auto_refresh_time
-- Only refresh if:
-- 1. Auto-refresh is enabled
-- 2. Reader UI exists and is active
-- 3. View is available
-- 4. Document is currently open (not on home screen/locked)
-- 5. Dimensions have been calculated
-- 6. Render mode is nil (epub-like documents only)
if auto_refresh and
current_reader_ui and
current_reader_ui.view and
current_reader_ui.document and
current_reader_ui.view.state and
current_reader_ui.view.state.page and
current_reader_ui.view.render_mode == nil and
dimen_refresh then
-- Use "fast" refresh mode and limit to header area only
UIManager:setDirty(current_reader_ui, "fast", dimen_refresh)
end
scheduleHeaderRefresh()
end)
end
-- Start the refresh cycle
scheduleHeaderRefresh()
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment