Last active
December 1, 2025 16:06
-
-
Save mallomar/1f91df2c1d4f46a30aecae5ae2374d8c to your computer and use it in GitHub Desktop.
Fork of @joshuacant's 2-reader-header-centered and 2-reader-header-cornered patches with improvements in collaboration with @JasonInOttawa (https://github.com/joshuacant/KOReader.patches/blob/main/2-reader-header-centered.lua, https://github.com/joshuacant/KOReader.patches/blob/main/2-reader-header-cornered.lua, https://github.com/JasonInOttawa/…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| --[[ | |
| 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