#!/usr/bin/python3
"""
jouno: Journal notifications forwarder
======================================

A GUI Systemd-Journal viewer with Freedesktop-Notifications forwarding including burst-handling and filtering.

Usage:
======

        jouno [-h]
                     [--about] [--detailed-help]
                     [--install] [--uninstall]

Optional arguments:
-------------------

      -h, --help            show this help message and exit
      --detailed-help       full help in markdown format
      --about               about jouno
      --install             installs the jouno in the current user's path and desktop application menu.
      --uninstall           uninstalls the jouno application menu file and script for the current user.

Description
===========

``Jouno`` is a GUI ``systemd-journal`` monitoring and viewing tool.  Jouno can filter and bundle messages for
forwarding to the desktop as standard *Freedesktop DBUS Notifications* (most linux desktop environments present
DBUS Notifications as popup messages). Jouno's feature set includes:

 * Journal live-view.
   + Overview table with live view.
   + Plain-text or regular-expression incremental-search and select.
   + Double-click access to the all 50+ journal entry fields, including easy cut and paste the text.
   + Configurable history length, configurable full or filtered view.
 * Journal forwarding.
   + Forwarding of filtered messages to the desktop as DBUS-notifications.
   + Journal message-burst bundling to minimise desktop notifications.
   + Controls and options to enable/disable forwarding.
   + Optional forwarding of the xorg-session.log or wayland-session.log to the systemd-journal (consolidated desktop
     logging).
 * Filtering
   + Filtering to include or exclude messages.
   + Plain-text and regular-expression filtering.
   + Easy filter creation from any selected journal entry.
   + Filters may be edited, deleted, reordered, or selectively enabled or disabled.
   + Filter editing feedback via incremental-search of past journal entries as you edit.
   + Filters are saved to the config file and reloaded at startup.
 * User interface and configuration
   + Panels undock for maximised or customised viewing.
   + Customised panel and window geometries are saved across application-restart and panel-docking.
   + Dynamic (no restart) support for desktop theme changes, including light/dark theme switching.
   + An option to run minimised in the system-tray with a quick-access tray context-menu.
   + Full configuration UI, editing of config INI files is not required.
   + If Config INI files are externally edited, the changes are automatically reloaded without requiring a restart.


``jouno`` is a tool designed to increase awareness of background activity by monitoring
the journal and raising interesting journal-entries as desktop notifications.  Possibilities for
it use include:

 * Monitoring specific jobs, such as the progress of the daily backups.
 * Watching for specific events, such as background core dumps.
 * Investigating desktop actions that raise journal log entries.
 * Discovering unnecessary daemon activity and unnecessary services.
 * Notifying access attempts, such as su, ssh, samba, or pam events.
 * Prevention of adverse desktop activity, such as shutting down during the backups.
 * Detecting hardware events.
 * Providing timer and cron jobs with a simple way to raise desktop notifications.
 * Raising general awareness of what is going on in the background.

Getting started
===============

Clicking on the ``jouno`` system-tray icon brings up an ``options and filters`` panel which includes three
tabs:

  1. Options: settings that adjust how to display messages and how to collate of bursts af messages

  2. Match Filters: filters that restrict notifications to only journal entries they match.

  3. Ignore Filters: filters that restrict notifications by ignoring journal entries they match.

Match-filtering is most useful when only minimal journal entries are of interest and the other entries aren't
of interest.  For example, a match-filter might be set for core-dump journal entries only.

Ignore-filtering is most useful when almost any journal entries might be of interest and only a few journal
items need to be ignored.  For example, if any unexpected messages might be of interest, ignore-filters could
be set up for any that are routine.

It's common to require a few ignore-filters to discard any messages generated by the desktop notification system
in response to notices being posted.

The user interface includes a few conveniences to assist with creating new patterns:

 * Any current message in the *Recently Notified* panel can be used as basis for a new filter rule by
   selecting the message's row and then pressing the *New Filter* button.  The message's text will
   be copied to the filter *pattern* field for editing into a pattern.
 * If no filter row is selected, new filters will at the end of the filter table, otherwise new filters
   are inserted above the current selection.
 * Filter ordering can be altered by drag and drop.
 * Filters can be temporarily enabled or disabled via the checkbox in the *Rule-ID* column.
 * Filters can be regular-expressions (Python-variant), just check the regular-expression checkbox in
   the *Pattern* column.
 * During incremental entry of the filter pattern, the *Recently notified* panel will highlight any
   messages matched by the pattern.

Match and Ignore Patterns
-------------------------

 * Journal entries are filtered by list of rules.  Each rule defines a ``Rule ID`` and a ``pattern.``

 * A rule can be a Match-Filters (notifying matching message) or an Ignore-Filter (ignore matching messages).

 * Match-Filters override Ignore-Filters, so you can ignore all the noise, and selectively override this for
   specific sources or items of interest.

 * Each rule is identified by a ``Rule ID`` which is text identifier compliant with commonly accepted
   variable naming conventions, for example:
   ```
   my_id
   my-id
   myId21
   ```
 * Patterns may match any fragment of text seen in actual journal entries, double click any journal
   entry's icon to see the full journal text that is available for matching.  Some examples:
   ```
   coredump
   NotificationPopup.
   sudo
   No object for name "alsa_output.usb-FiiO_DigiHug_USB_Audio-01.analog-stereo.monitor
   kernel
   /usr/sbin/cron
   daily-backup
   smartd
   /etc/services

 * If a pattern's regular expression checkbox is ticked, the pattern will be treated as a regular expression.

 * Patterns may be forced to match specific journal entry fields by using quotes to confine the match, for example:
   ```
   'SYSLOG_IDENTIFIER=su'
   '_GID=500',
   '_HOSTNAME=kosmos1'
   'PRIORITY=[123]',
   '_CMDLINE=/usr/bin/kded5'
   '_PID=2143',
   ```
   When attempting to match specific fields surround the pattern with single-quotes
   to ensure that complete values are matched, for example: ``'_GID=500'``  will
   only match the intended field and value, it won't match ``PARENT_GID=5000``.

   The list of possible field names can be found by double-clicking a message or by usined the names found at:

      [https://www.freedesktop.org/software/systemd/man/systemd.journal-fields.html](https://www.freedesktop.org/software/systemd/man/systemd.journal-fields.html)

   Not all fields are found in all messages, it's best to use actual messages as a basis for creating new
   patterns.

 * When matching, in the text being matched, field names appear in alphabetical order.  This allows
   regular expressions to be written to match a combination of fields.  For example, the regular expression
   (?<='PRIORITY=[45]')(.*)(?='_UID=1027')  would match PRIORITY 4 or 5 and _UID 1027 with
   any amount of text in between.

Config files
------------

All settings made in the *Configuration* panel are saved to a config file.  There is no need to manually
edit the config file, but if it is externally edited the application will automatically reload the changes.

Although the config file is theoretically optional, some filtering of the journal is likely to be necessary.
The application will cope with cascades of messages and won't cause infinite cascades, but filtering may be
necessary to eliminate excessive bursts caused by the desktop when it processes the notifications generated
by jouno.

The config file is in INI-format divided into a number of sections as outlined below::
```
        # The options section controls notice timeouts, burst treatment
        [options]
        # Polling interval, how often to wait for journal entries between checking for config changes
        poll_seconds = 2
        # Wait at lease burst_seconds before declaring a burst of messages to have finished
        burst_seconds = 5
        # Only show the the first burst_truncate_messages of a burst
        burst_truncate_messages = 3
        # Set journo messages to timeout/auto-dismiss after notification-seconds
        notification_seconds = 30
        # The maximum number of journal items to display in the "Recently notified" table.
        journal_history_max = 100
        # Run out of the system tray
        system_tray_enabled = yes
        # Start the application with notifications enabled (disable notifications from start up).
        start_with_notifications_enabled = yes
        # List all messages in the "Recently notified" table, not just the ones that passed the filters.
        list_all_enabled = yes
        # Show older messages from boot onward
        from_boot_enabled = no
        # For debugging the application
        debug_enabled = yes

        [match]
        # Each filter rule has an id and the message text to match
        my_rule_id = forward journal entry if this string matches
        # Each filter rule can be disabled by a corresponding my_rule_id_enabled = no option
        my_rule_id_enabled = no
        # A filter id that ends in _regexp is treated as a python regular-expression
        my_other_rule_id_regexp = forward journal [Ee]ntry if this python-regexp matches

        [ignore]
        my_ignore_rule_id = ignore journal entry if this string matches
        my_ignore_other_rule_id_regexp = ignore [Jj]ournal entry if this python-regexp matches
```

The config file is normally save to a standard desktop location:

        $HOME/.config/jouno/jouno.conf

In addition to the application config file, window geometry and state is saved to:

        $HOME/.config/jouno.qt.state/jouno.conf


Prerequisites
=============

All the following runtime dependencies are likely to be available pre-packaged on any modern Linux distribution
(``jouno`` was originally developed on OpenSUSE Tumbleweed).

* python 3.8: ``jouno`` is written in python and may depend on some features present only in 3.8 onward.
* python 3.8 QtPy: the python GUI library used by ``jouno``.
* python 3.8 systemd: python module for native access to the systemd facilities.
* python 3.8 dbus: python module for dbus used for issuing notifications

Dependency installation on ``OpenSUSE``:

        zypper install python38-QtPy python38-systemd python38-dbus

If you want to be able to read all of a system's journal entries you will need to be a member of the Linux
systemd-journal group.

Optional Accessories
====================

A suggested accessory is [KDE Connect](https://kdeconnect.kde.org/).  If you enabled the appropriate permissions on
your phone, KDE Connect can forward desktop notifications to the phone.  Use Jouno to forward Systemd-Journal
messages to Desktop-Notifications, and use KDE Connect to forward them to your phone.


jouno Copyright (C) 2021 Michael Hamilton
===========================================

This program is free software: you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the
Free Software Foundation, version 3.

This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
more details.

You should have received a copy of the GNU General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>.

**Contact:**  m i c h a e l   @   a c t r i x   .   g e n   .   n z

----------

"""

# TODO figure out why QIntValidator is only working approximately.
# TODO refine Apply/Revert and dynamically enable/disable the buttons.

import argparse
import configparser
import datetime as DT
import grp
import os
import pwd
import re
import select
import signal
import stat
import sys
import textwrap
import time
import traceback
import typing
import weakref
from enum import Enum
from functools import partial
from html import escape
from io import StringIO
from pathlib import Path
from typing import Mapping, Any, List, Type, Callable, Tuple, Union, Iterator, TextIO

import dbus
import pytz
from PyQt5.QtCore import QCoreApplication, QProcess, Qt, pyqtSignal, QThread, QModelIndex, QItemSelectionModel, QSize, \
    QEvent, QSettings, QObject, QItemSelection, QPoint, QDateTime, QDate
from PyQt5.QtGui import QPixmap, QIcon, QImage, QPainter, QStandardItemModel, QStandardItem, QIntValidator, \
    QFontDatabase, QGuiApplication, QCloseEvent, QPalette, QTextCursor, QColor
from PyQt5.QtSvg import QSvgRenderer
from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QMessageBox, QLineEdit, QLabel, \
    QPushButton, QSystemTrayIcon, QMenu, QTextEdit, QDialog, QTabWidget, \
    QCheckBox, QGridLayout, QTableView, \
    QAbstractItemView, QHeaderView, QMainWindow, QSizePolicy, QStyledItemDelegate, QToolBar, QDockWidget, \
    QHBoxLayout, QStyleFactory, QToolButton, QScrollArea, QLayout, QStatusBar, QDateTimeEdit, QCalendarWidget, \
    QFormLayout, QGroupBox, QSpacerItem, QTableWidgetItem, QTableWidget, \
    QProgressDialog
from systemd import journal

JOUNO_VERSION = '1.3.6'

JOUNO_CONSOLIDATED_TEXT_KEY = '___JOURNO_FULL_TEXT___'

# On Plasma Wayland the system tray may not be immediately available at login - so keep trying for...
SYSTEM_TRAY_WAIT_SECONDS = 20

# The icons can either be:
#   1) str: named icons from the freedesktop theme which should all be available on most Linux desktops.
#      https://specifications.freedesktop.org/icon-naming-spec/icon-naming-spec-latest.html
#   1) bytes: SVG strings for any icons that are custom to this application.
# The load_icon() function dynamically figures out which so we can
# switch from one source to another without editing the code proper
# TODO: consider moving the icon definitions to a file read at startup.

ICON_HELP_ABOUT = "help-about"
ICON_HELP_CONTENTS = "help-contents"
ICON_APPLICATION_EXIT = "application-exit"
ICON_CONTEXT_MENU_LISTENING_ENABLE = "view-refresh"
ICON_CONTEXT_MENU_LISTENING_DISABLE = "process-stop"
ICON_TRAY_LISTENING_DISABLED = ICON_CONTEXT_MENU_LISTENING_DISABLE
ICON_COPY_TO_CLIPBOARD = "edit-copy"
ICON_SEARCH_TEXT = "system-search"
ICON_UNDOCK = "window-new"
ICON_DOCK = "view-restore"
ICON_GO_NEXT = "go-down"
ICON_GO_PREVIOUS = "go-up"
ICON_CLEAR_RECENTS = "edit-clear-all"
ICON_REVERT = 'edit-undo'
ICON_APPLY_AND_RESTART = 'edit-redo'
ICON_WINDOW_CLOSE = 'window-close'
# This might only be KDE/Linux icons - not in Freedesktop Standard.
ICON_APPLY = "dialog-ok-apply"
ICON_VIEW_JOURNAL_ENTRY = 'view-fullscreen'
ICON_CLEAR_SELECTION = 'edit-undo'
ICON_COPY_SELECTED = 'edit-copy'
ICON_PLAIN_TEXT_SEARCH = 'insert-text'
ICON_REGEXP_SEARCH = 'list-add'
ICON_SETTINGS_CONFIGURE = 'settings-configure'

SVG_LIGHT_THEME_COLOR = b"#232629"
SVG_DARK_THEME_COLOR = b"#f3f3f3"

SVG_JOUNO = b"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
 <path fill="#232629" style="fill:currentColor;fill-opacity:1;stroke:none" 
      d="M 4 2 L 4 3 L 13 3 L 13 13 L 4 13 L 4 14 L 13 14 L 14 14 L 14 3 L 14 2 L 7 2 z"
      class="ColorScheme-Text"
     />
 <path fill="#3491e1" style="fill-opacity:1;stroke:none" 
      d="M 8 6 L 8 8 L 12 8 L 12 7 L 8 7 z M 8 8 L 8 10 L 12 10 L 12 9 L 8 9 z M 8 11 L 8 12 L 12 12 L 12 11 L 10 11 z "

     />

</svg>
"""
SVG_JOUNO_LIGHT = SVG_JOUNO.replace(SVG_LIGHT_THEME_COLOR, b'#bbbbbb')

SVG_TOOLBAR_RUN_DISABLED = b"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
    <style type="text/css" id="current-color-scheme">
        .ColorScheme-Text {
            color:#232629;
        }
    </style>
    <path d="m3 3v16l16-8z" class="ColorScheme-Text" fill="currentColor"/>
</svg>
"""

SVG_TOOLBAR_RUN_ENABLED = SVG_TOOLBAR_RUN_DISABLED.replace(b"#232629;", b"#3daee9;")
SVG_TOOLBAR_STOP = b"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
    <style type="text/css" id="current-color-scheme">
        .ColorScheme-Text {
            color:#da4453;
        }
    </style>
    <path d="m3 3h16v16h-16z" class="ColorScheme-Text" fill="currentColor"/>
</svg>
"""
SVG_TOOLBAR_NOTIFIER_ENABLED = b"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
  <defs id="defs3051">
    <style type="text/css" id="current-color-scheme">
      .ColorScheme-Text {
        color:#379fd3;
      }
      </style>
  </defs>
 <path style="fill:currentColor;fill-opacity:1;stroke:none"
       d="M 3 4 L 3 16 L 6 20 L 6 17 L 6 16 L 19 16 L 19 4 L 3 4 z M 4 5 L 18 5 L 18 15 L 4 15 L 4 5 z M 16 6 L 9.5 12.25 L 7 10 L 6 11 L 9.5 14 L 17 7 L 16 6 z "
     class="ColorScheme-Text"
     />
</svg>
"""
SVG_TOOLBAR_NOTIFIER_DISABLED = b"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
  <defs id="defs3051">
    <style type="text/css" id="current-color-scheme">
      .ColorScheme-Text {
        color:#da4453;
      }
      </style>
  </defs>
 <path style="fill:currentColor;fill-opacity:1;stroke:none"
       d="M 3 4 L 3 16 L 6 20 L 6 17 L 6 16 L 19 16 L 19 4 L 3 4 z M 4 5 L 18 5 L 18 15 L 4 15 L 4 5 z M 8 6 L 7 7 L 10 10 L 7 13 L 8 14 L 11 11 L 14 14 L 15 13 L 12 10 L 15 7 L 14 6 L 11 9 L 8 6 z "
     class="ColorScheme-Text"
     />
</svg>
"""
SVG_TOOLBAR_ADD_FILTER = b"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
  <defs id="defs3051">
    <style type="text/css" id="current-color-scheme">
      .ColorScheme-Text {
        color:#232629;
      }
      </style>
  </defs>
 <path 
     style="fill:currentColor;fill-opacity:1;stroke:none" 
     d="M 5 3 L 4 4 L 4 5 L 4 5.3046875 L 9 12.367188 L 9 16 L 9 16.039062 L 12.990234 19 L 13 19 L 13 12.367188 L 18 5.3046875 L 18 4 L 17 3 L 5 3 z M 5 4 L 17 4 L 17 4.9882812 L 12.035156 12 L 12 12 L 12 12.048828 L 12 13 L 12 17.019531 L 10 15.535156 L 10 13 L 10 12.048828 L 10 12 L 9.9648438 12 L 5 4.9882812 L 5 4 z M 6 5 L 8 8 L 8 6 L 10 5 L 6 5 z M 16 14 L 16 16 L 14 16 L 14 17 L 16 17 L 16 19 L 17 19 L 17 17 L 19 17 L 19 16 L 17 16 L 17 14 L 16 14 z "
     class="ColorScheme-Text"
     />
</svg>
"""

SVG_TOOLBAR_DEL_FILTER = b"""
<svg id="svg8" version="1.1" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
    <defs id="defs3051">
        <style id="current-color-scheme" type="text/css">.ColorScheme-Text {
        color:#232629;
      }</style>
    </defs>
    <path id="path4" class="ColorScheme-Text" d="m5 3-1 1v1.3046875l5 7.0625005v3.671872l3 2.226563v-1.246092l-2-1.484375v-3.535156h-0.035156l-4.964844-7.0117188v-0.9882812h12v0.9882812l-4.964844 7.0117188h1.22461l4.740234-6.6953125v-1.3046875l-1-1zm1 2 2 3v-2l2-1z" fill="currentColor"/>
    <path id="path6" d="M 13.990234,13 13,13.990234 15.009766,16 13,18.009766 13.990234,19 16,16.990234 18.009766,19 19,18.009766 16.990234,16 19,13.990234 18.009766,13 16,15.009766 Z" fill="#da4453"/>
</svg>
"""

SVG_TOOLBAR_TEST_FILTERS = b"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
  <defs id="defs3051">
    <style type="text/css" id="current-color-scheme">
      .ColorScheme-Text {
        color:#232629;
      }
      </style>
  </defs>
 <path style="fill:currentColor;fill-opacity:1;stroke:none" 
     d="M 7 2 L 7 3.1015625 A 5 5 0 0 0 5.2460938 3.8320312 L 4.4648438 3.0507812 L 3.0507812 4.4648438 L 3.8320312 5.2460938 A 5 5 0 0 0 3.1054688 7 L 2 7 L 2 9 L 3.1015625 9 A 5 5 0 0 0 3.8320312 10.753906 L 3.0507812 11.535156 L 4.4648438 12.949219 L 5.2460938 12.167969 A 5 5 0 0 0 7 12.894531 L 7 14 L 9 14 L 9 12.898438 L 9 11.869141 A 4 4 0 0 1 8 12 A 4 4 0 0 1 5.1308594 10.787109 A 4 4 0 0 1 4 8 A 4 4 0 0 1 5.2128906 5.1308594 A 4 4 0 0 1 8 4 A 4 4 0 0 1 10.869141 5.2128906 A 4 4 0 0 1 12 8 L 14 8 L 14 7 L 12.898438 7 A 5 5 0 0 0 12.167969 5.2460938 L 12.949219 4.4648438 L 11.535156 3.0507812 L 10.753906 3.8320312 A 5 5 0 0 0 9 3.1054688 L 9 2 L 7 2 z M 7 6 L 7 10 L 10 8 L 7 6 z M 10 9 L 10 10 L 14 10 L 14 9 L 10 9 z M 10 11 L 10 12 L 14 12 L 14 11 L 10 11 z M 10 13 L 10 14 L 14 14 L 14 13 L 10 13 z "
     class="ColorScheme-Text"
     />
</svg>
"""

SVG_TOOLBAR_QUERY_JOURNAL = b"""
<!DOCTYPE svg>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 22 22">
    <defs>
        <style id="current-color-scheme" type="text/css">
            .ColorScheme-Text {
                color:#232629;
            }
        </style>
    </defs>
    <path style="fill:currentColor; fill-opacity:1; stroke:none" class="ColorScheme-Text" d="M 6 3 C 4.929 3 3.93784 3.57249 3.40234 4.5 C 3.13459 4.96375 3 5.48188 3 6 L 3 16 C 3 16.5181 3.1346 17.0362 3.40234 17.5 C 3.93784 18.4275 4.929 19 6 19 L 16 19 L 16 18 L 6 18 C 5.28467 18 4.62524 17.6195 4.26758 17 C 3.90991 16.3805 3.90991 15.6195 4.26758 15 C 4.62524 14.3805 5.28467 14 6 14 L 7 14 L 7 7 L 17 7 L 17 15 L 18 15 L 18 6 L 7 6 L 7 3 L 6 3 Z M 6 4 L 6 6 L 6 7 L 6 13 C 5.24975 13 4.5427 13.2863 4 13.7734 L 4 6 C 4 5.65487 4.08874 5.30975 4.26758 5 C 4.62524 4.3805 5.28467 4 6 4 Z"/>
    <path style="fill:currentColor; fill-opacity:1; stroke:none" class="ColorScheme-Text" d="M 12 8 C 9.79086 8 8 9.79086 8 12 C 8 14.2091 9.79086 16 12 16 C 12.8874 15.9982 13.749 15.7014 14.4492 15.1563 L 18.293 19 L 19 18.293 L 15.1582 14.4512 C 15.7031 13.7502 15.9992 12.8878 16 12 C 16 9.79086 14.2091 8 12 8 Z M 12 9 C 13.6569 9 15 10.3431 15 12 C 15 13.6569 13.6569 15 12 15 C 10.3431 15 9 13.6569 9 12 C 9 10.3431 10.3431 9 12 9 Z"/>
</svg>
"""

SVG_TOOLBAR_HAMBURGER_MENU = b"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 22">
  <defs id="defs3051">
    <style type="text/css" id="current-color-scheme">
      .ColorScheme-Text {
        color:#232629;
      }
      </style>
  </defs>
 <path 
     style="fill:currentColor;fill-opacity:1;stroke:none" 
	d="m3 5v2h16v-2h-16m0 5v2h16v-2h-16m0 5v2h16v-2h-16"
	 class="ColorScheme-Text"
     />
</svg>
"""

TABLE_HEADER_STYLE = "font-weight: bold;font-size: 9pt;"

ABOUT_TEXT = f"""

<b>jouno version {JOUNO_VERSION}</b>
<p>
A Systemd-Journal viewer with Freedesktop-Notifications forwarding including burst-handling and filtering.
<p>
Visit <a href="https://github.com/digitaltrails/jouno">https://github.com/digitaltrails/jouno</a> for 
more details.
<p><p>

<b>jouno Copyright (C) 2021 Michael Hamilton</b>
<p>DEFAULT_QUERY_FIELDS = ['_UID', '_GID', 'QT_CATEGORY', 'PRIORITY', 'SYSLOG_IDENTIFIER',
                        '_COM', '_EXE', '_HOSTNAME', 'COREDUMP_COMM', 'COREDUMP_EXE']
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the
Free Software Foundation, version 3.
<p>
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
more details.
<p>
You should have received a copy of the GNU General Public License along
with this program. If not, see <a href="https://www.gnu.org/licenses/">https://www.gnu.org/licenses/</a>.

"""

STATUS_TIMEOUT_MSEC = 10000
STATUS_SHORT_TIMEOUT_MSEC = 5000
STATUS_LONG_TIMEOUT_MSEC = 30000

ERROR_DBUS_NOTIFICATIONS_UNAVAILABLE = "DBUS notification service unavailable"
ERROR_DBUS_NOTIFICATION_FAILED = "DBUS notification failed"

DEFAULT_QUERY_FIELDS = ['_UID', '_GID', 'QT_CATEGORY', 'PRIORITY', 'SYSLOG_IDENTIFIER',
                        '_COM', '_EXE', 'COREDUMP_COMM', 'COREDUMP_EXE', '_HOSTNAME', ]

DEFAULT_CONFIG = f'''
[options]
poll_seconds = 5
burst_seconds = 5
burst_truncate_messages = 6
notification_seconds = 30
journal_history_max = 500
system_tray_enabled = no
dark_tray_enabled = no
start_with_notifications_enabled = yes
list_all_enabled = no
forward_session_log_enabled = no
debug_enabled = no
query_field_list = {' '.join(DEFAULT_QUERY_FIELDS)}

[ignore] 
kwin_bad_damage = XCB error: 152 (BadDamage)
kwin_bad_window = kwin_core: XCB error: 3 (BadWindow)
self_caused = NotificationPopup.
qt_kde_binding_loop = Binding loop detected for property

[match]

'''


class ConfigOption:

    def __init__(self, option_id: str, tooltip: str, int_range: Tuple[int, int] = None):
        self.option_id = option_id
        self.int_range = int_range
        self._tooltip = tooltip

    def label(self):
        return tr(self.option_id).replace('_', ' ').capitalize()

    def tooltip(self):
        fmt = tr(self._tooltip)
        return fmt.format(self.int_range[0], self.int_range[1]) if self.int_range is not None else fmt


CONFIG_OPTIONS_LIST: List[ConfigOption] = [
    ConfigOption('poll_seconds', 'How often to poll for new messages ({}..{} seconds).', (1, 30)),
    ConfigOption('burst_seconds', 'How long to wait for a burst of messages to complete ({}..{} seconds).', (1, 30)),
    ConfigOption('burst_truncate_messages',
                 'How many messages from a burst should be bundled into its desktop notification ({}..{} messages).',
                 (1, 50)),
    ConfigOption('notification_seconds',
                 'How long should a desktop notification remain visible, zero for no timeout ({}..{} seconds)',
                 (0, 60)),
    ConfigOption('journal_history_max',
                 'How many journal entries should be shown in the Recently Notified panel.', None),
    ConfigOption('system_tray_enabled', 'Jouno should start minimised in the system-tray.'),
    ConfigOption('dark_tray_enabled', 'System tray is dark colored.'),
    ConfigOption('start_with_notifications_enabled', 'Jouno should start with desktop notifications enabled.'),
    ConfigOption('list_all_enabled', 'The Recent notifications panel should show all entries, including non-notified.'),
    ConfigOption('from_boot_enabled', 'Show old journal entries from boot onward.'),
    ConfigOption('forward_session_log_enabled',
                 'Forward xorg-session.log or wayland-session.log to the systemd-journal (if it exists).'),
    ConfigOption('debug_enabled', 'Enable extra debugging output to standard-out.'),
    ConfigOption('query_field_list', 'Default query fields.'),
]


# ######################## MONITOR SUB PROCESS CODE ###############################################################
# TODO The monitor code has been written so it can be extracted to a future non pyqt command line utility.

class Priority(Enum):
    EMERGENCY = 0
    ALERT = 1
    CRITICAL = 2
    ERR = 3
    WARNING = 4
    NOTICE = 5
    INFO = 6
    DEBUG = 7


NOTIFICATION_ICONS = {
    Priority.EMERGENCY: 'dialog-error',
    Priority.ALERT: 'dialog-error',
    Priority.CRITICAL: 'dialog-error',
    Priority.ERR: 'dialog-error',
    Priority.WARNING: 'dialog-warning',
    Priority.NOTICE: 'dialog-information',
    Priority.INFO: 'dialog-information',
    Priority.DEBUG: 'dialog-information',
}

debugging = True


def debug(*arg):
    if debugging:
        print('DEBUG:', *arg)


def info(*arg):
    print('INFO:', *arg)


def warning(*arg):
    print('WARNING:', *arg)


def error(*arg):
    print('ERROR:', *arg)


class NotifyFreeDesktop:

    def __init__(self):
        self.notify_interface = dbus.Interface(
            object=dbus.SessionBus().get_object("org.freedesktop.Notifications", "/org/freedesktop/Notifications"),
            dbus_interface="org.freedesktop.Notifications")

    def notify_desktop(self, app_name: str, summary: str, message: str, priority: Priority, timeout: int):
        if self.notify_interface == None:
            self.notify_interface = dbus.Interface(
                object=dbus.SessionBus().get_object("org.freedesktop.Notifications", "/org/freedesktop/Notifications"),
                dbus_interface="org.freedesktop.Notifications")

        # https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html
        if self.notify_interface is not None:
            replace_id = 0
            notification_icon = NOTIFICATION_ICONS[priority] + ".png"
            action_requests = []
            # extra_hints = {"urgency": 1, "sound-name": "dialog-warning", }
            extra_hints = {}
            try:
                self.notify_interface.Notify(app_name,
                                             replace_id,
                                             notification_icon,
                                             escape(summary).encode('UTF-8'),
                                             escape(message).encode('UTF-8'),
                                             action_requests,
                                             extra_hints,
                                             timeout)
            except dbus.exceptions.DBusException as e:
                # Force reinit on next use
                self.notify_interface = None
                raise e


def get_config_path() -> Path:
    config_dir_path = Path.home().joinpath('.config').joinpath('jouno')
    if not config_dir_path.parent.is_dir() or not config_dir_path.is_dir():
        os.makedirs(config_dir_path)
    path = config_dir_path.joinpath('jouno.conf')
    return path


class Config(configparser.ConfigParser):

    def __init__(self):
        super().__init__()
        self.path = get_config_path()
        self.modified_time = 0.0
        self.read_string(DEFAULT_CONFIG)

    def save(self):
        if self.path.exists():
            self.path.rename(self.path.with_suffix('.bak'))
        with self.path.open('w') as config_file:
            self.write(config_file)

    def refresh(self) -> bool:
        if self.path.is_file():
            modified_time = self.path.lstat().st_mtime
            if self.modified_time == modified_time:
                return False
            self.modified_time = modified_time
            info(f"Config: reading {self.path}")
            config_text = self.path.read_text()
            for section in ['match', 'ignore']:
                self.remove_section(section)
            self.read_string(config_text)
            for section in ['options', 'match', 'ignore']:
                if section not in self:
                    self[section] = {}
            return True
        if self.modified_time > 0.0:
            info(f"Config file has been deleted: {self.path}")
            self.modified_time = 0.0
        return False

    def is_different(self, other: 'Config'):
        with StringIO() as io1, StringIO() as io2:
            self.write(io1)
            other.write(io2)
            return io1.getvalue() != io2.getvalue()


def determine_source(journal_entry):
    for key in ['_KERNEL_SUBSYSTEM', 'SYSLOG_IDENTIFIER', '_COMM', '_EXE', '_CMDLINE', ]:
        if key in journal_entry:
            value = str(journal_entry[key])
            if key == '_KERNEL_SUBSYSTEM':
                value = 'kernel: ' + value
            return value
    return 'unknown'


def consolidate_text(journal_entry):
    # Use an easy a format that is easy to pattern match
    # The sort is going to cost us - this seems to be the fastest way to do it
    fields_str = ', '.join((f"'{key}={journal_entry[key]}'" for key in sorted(journal_entry.keys())))
    # Prepend the source, so it's searchable by entering what is seen in the UI
    journal_entry[JOUNO_CONSOLIDATED_TEXT_KEY] = f"source={determine_source(journal_entry)}, {fields_str}"
    return fields_str


def determine_priority(journal_entries: List[Mapping[str, Any]]) -> Priority:
    current_level = Priority.NOTICE
    for journal_entry in journal_entries:
        if 'PRIORITY' in journal_entry:
            priority = journal_entry['PRIORITY']
            if priority < current_level.value and (Priority.EMERGENCY.value <= priority <= Priority.DEBUG.value):
                current_level = Priority(priority)
    return current_level


class JournalWatcher:

    def __init__(self, supervisor: 'JournalWatcherTask'):
        self.burst_truncate: int = 3
        self.polling_millis: int = 2_000
        self.notification_timeout_millis: int = 60_000
        self.burst_max_millis = 10_000
        self.ignore_regexp: Mapping[str, re] = {}
        self.match_regexp: Mapping[str, re] = {}
        self.forward_all = False
        self.max_historical_entries = 500
        self.from_boot_enabled = False
        self.limit_from_boot = 0
        self.config = Config()
        self.update_settings_from_config()
        self._stop = False
        self.supervisor = supervisor
        self.notifications_enabled = True
        self.deliver_history = True

    def is_notifying(self) -> bool:
        return self.notifications_enabled

    def enable_notifications(self, enable: bool):
        self.notifications_enabled = enable

    def enable_forward_all(self, enable: bool):
        self.forward_all = enable

    def update_settings_from_config(self):
        info('JournalWatcher reading config.')
        self.config.refresh()
        if 'poll_seconds' in self.config['options']:
            self.polling_millis = 1_000 * self.config.getint('options', 'poll_seconds')
        if 'burst_truncate_messages' in self.config['options']:
            self.burst_truncate = self.config.getint('options', 'burst_truncate_messages')
        if 'burst_seconds' in self.config['options']:
            self.burst_max_millis = 1_000 * self.config.getint('options', 'burst_seconds')
        if 'notification_seconds' in self.config['options']:
            self.notification_timeout_millis = 1_000 * self.config.getint('options', 'notification_seconds')
        if 'list_all_enabled' in self.config['options']:
            self.forward_all = self.config.getboolean('options', 'list_all_enabled')
        if 'journal_history_max' in self.config['options']:
            self.max_historical_entries = self.config.getint('options', 'journal_history_max')
        if 'from_boot_enabled' in self.config['options']:
            self.from_boot_enabled = self.config.getboolean('options', 'from_boot_enabled')
        if 'debug' in self.config['options']:
            global debugging
            debugging = self.config.getboolean('options', 'debug')
            info("Debugging output is disabled.") if not debugging else None
        self.ignore_regexp: Mapping[str, re] = {}
        self.match_regexp: Mapping[str, re] = {}
        self.compile_patterns(self.config['match'], self.match_regexp)
        self.compile_patterns(self.config['ignore'], self.ignore_regexp)

    def compile_patterns(self, rules_map: Mapping[str, str], patterns_map: Mapping[str, re.Pattern]):
        for rule_id, rule_text in rules_map.items():
            if rule_id.endswith('_enabled'):
                pass
            else:
                rule_enabled_key = rule_id + "_enabled"
                re_indicator_key = rule_id + "_regexp_enabled"
                if rule_enabled_key not in rules_map or rules_map[rule_enabled_key].lower() == 'yes':
                    if re_indicator_key in rules_map and rules_map[re_indicator_key].lower() == 'yes':
                        patterns_map[rule_id] = re.compile(rule_text, flags=re.DOTALL)
                    else:
                        patterns_map[rule_id] = re.compile(re.escape(rule_text), flags=re.DOTALL)

    def determine_source(self, journal_entry):
        for key in ['_COMM', '_EXE', '_CMDLINE', '_KERNEL_SUBSYSTEM', 'SYSLOG_IDENTIFIER', ]:
            if key in journal_entry:
                value = str(journal_entry[key])
                if key == '_KERNEL_SUBSYSTEM':
                    value = 'kern: ' + value
                return value
        return 'unknown'

    def determine_app_names(self, journal_entries: List[Mapping[str, Any]]):
        app_name_info = ''
        sep = '\u25b3'
        for journal_entry in journal_entries:
            source = determine_source(journal_entry)
            if app_name_info.find(source) < 0:
                app_name_info += sep + source
                sep = '; '
        if app_name_info == '':
            app_name_info = sep + 'unknown'
        return app_name_info

    def determine_summary(self, journal_entries: List[Mapping[str, Any]]):
        journal_entry = journal_entries[0]
        realtime = journal_entry['__REALTIME_TIMESTAMP']
        transport = f" {journal_entry['_TRANSPORT']}" if '_TRANSPORT' in journal_entry else ''
        number_of_entries = len(journal_entries)
        if number_of_entries > 1:
            summary = f"\u25F4{realtime:%H:%M:%S}:{transport} Burst of {number_of_entries} messages"
        else:
            text = ''
            sep = ''
            for key, prefix in {'SYSLOG_IDENTIFIER': '', '_PID': 'PID ', '_KERNEL_SUBSYSTEM': 'kernel ', }.items():
                if key in journal_entry:
                    value = str(journal_entry[key])
                    if text.find(value) < 0:
                        text += sep + prefix + value
                        sep = ' '
            summary = f"\u25F4{realtime:%H:%M:%S}: {text} (\u21e8{transport})"
        # debug(f"realtime='{realtime}' summary='{summary}'") if debugging else None
        return summary

    def determine_message(self, journal_entries: List[Mapping[str, Any]]) -> str:
        message = ''
        sep = ''
        previous_message = ''
        duplicates = 0
        reported = 0
        for journal_entry in journal_entries:
            new_message = journal_entry['MESSAGE']
            if new_message == previous_message:
                duplicates += 1
            else:
                message += f"{sep}\u25B7{new_message}"
                previous_message = new_message
                reported += 1
                if reported == self.burst_truncate and reported < len(journal_entries):
                    message += f"\n[Only showing first {self.burst_truncate} messages]"
                    break
            sep = '\n'
        if duplicates > 0:
            message += f'\n[{duplicates + 1} duplicate messages]'
        # debug(f'message={message}') if debugging else None
        return message

    def is_notable(self, fields_str: str):
        # debug(fields_str) if debugging else None
        # If there is nothing to match, then by default the entry is notable
        notable = True
        # Filter ignores first and see if the entry should be ignored
        for rule_id, ignore_re in self.ignore_regexp.items():
            if ignore_re.search(fields_str) is not None:
                # debug(f"rule=ignore.{rule_id}: ") if debugging else None
                notable = False
                break
        # Lastly, if we're going to ignore this entry, see if a match overrides this:
        if not notable:
            for rule_id, match_re in self.match_regexp.items():
                if match_re.search(fields_str) is not None:
                    # debug(f"rule=match.{rule_id}: ") if debugging else None
                    notable = True
                    break

        return notable

    def is_stop_requested(self) -> bool:
        return self.supervisor.isInterruptionRequested()

    def watch_journal(self):
        self._stop = False
        self.update_settings_from_config()

        with journal.Reader() as journal_reader:

            if self.deliver_history:
                self.load_past_entries(journal_reader)
                self.deliver_history = False

            journal_reader.seek_tail()
            journal_reader.get_previous()

            journal_reader_poll = select.poll()
            journal_reader_poll.register(journal_reader, journal_reader.get_events())
            journal_reader.add_match()
            notifier = None
            while True:
                if self.is_stop_requested():
                    return
                if self.config.refresh():
                    self.update_settings_from_config()
                if self.notifications_enabled and notifier is None:
                    try:
                        notifier = NotifyFreeDesktop()
                    except dbus.exceptions.DBusException as e:
                        self.supervisor.signal_error.emit(ERROR_DBUS_NOTIFICATIONS_UNAVAILABLE, e)
                        self.notifications_enabled = False
                burst_count = 0
                notable_list = []
                limit_time_ns = self.burst_max_millis * 1_000_000 + time.time_ns()
                while journal_reader_poll.poll(self.polling_millis) and time.time_ns() < limit_time_ns:
                    if self.is_stop_requested():
                        return
                    if journal_reader.process() == journal.APPEND:
                        for journal_entry in journal_reader:
                            if self.is_stop_requested():
                                return
                            burst_count += 1
                            notable = self.is_notable(consolidate_text(journal_entry))
                            notable_list.append(journal_entry) if notable else None
                            if notable or self.forward_all:
                                self.supervisor.new_journal_entry(journal_entry, notable)
                if self.notifications_enabled and len(notable_list):
                    try:
                        notifier.notify_desktop(app_name=self.determine_app_names(notable_list),
                                                summary=self.determine_summary(notable_list),
                                                message=self.determine_message(notable_list),
                                                priority=determine_priority(notable_list),
                                                timeout=self.notification_timeout_millis)
                    except dbus.exceptions.DBusException as e:
                        self.supervisor.signal_error.emit(ERROR_DBUS_NOTIFICATION_FAILED, e)

    def load_past_entries(self, journal_reader):
        data = []
        if self.from_boot_enabled:
            journal_reader.this_boot()
        elif self.max_historical_entries != 0:
            journal_reader.add_match()
            journal_reader.seek_tail()
            journal_reader.get_next(-self.max_historical_entries - 1)
        else:
            self.supervisor.deliver_historical_entries([])
            return
        results = []
        count = 0
        last_time = 0.0
        for journal_entry in journal_reader:
            notable = self.is_notable(consolidate_text(journal_entry))
            if notable or self.forward_all:
                results.append((journal_entry, notable,))
                count += 1
                if self.max_historical_entries != 0 and count > self.max_historical_entries:
                    results.pop(0)
            now = time.time()
            if now - last_time > 0.2:
                self.supervisor.report_historical_progress(count)
                last_time = now
        self.supervisor.report_historical_progress(count)
        self.supervisor.deliver_historical_entries(results)


def extract_source_from_considated_text(consolidated_text: str):
    return consolidated_text[len('source='):consolidated_text.index(',')]


def tr(source_text: str):
    """For future internationalization - recommended way to do this at this time."""
    return QCoreApplication.translate('jouno', source_text)


def find_most_recent_file(name_list: List[str], search_paths: List[Path]):
    latest = None
    for path in search_paths:
        for root, dirs, files in os.walk(path):
            for name in name_list:
                if name in files:
                    possible = os.path.join(root, name)
                    print(possible)
                    if latest is None or os.path.getmtime(possible) > os.path.getmtime(latest):
                        latest = possible
    return latest


class ForwardFileTask(QThread):
    def __init__(self, path: os.path, tail_only: bool = False):
        super().__init__()
        self.path = path
        self.tail_only = tail_only
        self.syslog_identifier = os.path.basename(path)
        self.stop = False

    def run(self):
        self.stop = False
        with open(self.path, 'r') as file:
            if self.tail_only:
                file.seek(0, 2)
            for line in self.__follow(file):
                journal.send(line, SYSLOG_IDENTIFIER=self.syslog_identifier)

    def __follow(self, file: TextIO) -> Iterator[str]:
        # gathers multiple lines into one incident
        line = ''
        time_read = 0.0
        while True:
            new_text = file.readline()
            if new_text is None:
                # Can this even happen?
                return
            elif new_text == '':
                # wait a bit in case this line is part of a group of lines
                if line.endswith("\n") and time_read + 0.25 < time.time():
                    yield line
                    line = ''
                time.sleep(0.25)
            else:
                time_read = time.time()
                line += new_text
            # deal with a large stream of data or data that is not newline terminated for an extended period
            if line != '' and time_read + 2.0 < time.time():
                yield line
                line = ''


# ######################## USER INTERFACE CODE ######################################################################

def is_dark_theme():
    # Heuristic for checking for a dark theme.
    # Is the sample text lighter than the background?
    label = QLabel("am I in the dark?")
    text_hsv_value = label.palette().color(QPalette.WindowText).value()
    bg_hsv_value = label.palette().color(QPalette.Background).value()
    dark_theme_found = text_hsv_value > bg_hsv_value
    # debug(f"is_dark_them text={text_hsv_value} bg={bg_hsv_value} is_dark={dark_theme_found}") if debugging else None
    return dark_theme_found


def create_image_from_svg_bytes(svg_str: bytes) -> QImage:
    """There is no QIcon option for loading QImage from a string, only from a SVG file, so roll our own."""
    if is_dark_theme():
        svg_str = svg_str.replace(SVG_LIGHT_THEME_COLOR, SVG_DARK_THEME_COLOR)
    renderer = QSvgRenderer(svg_str)
    image = QImage(64, 64, QImage.Format_ARGB32)
    image.fill(0x0)
    painter = QPainter(image)
    renderer.render(painter)
    painter.end()
    return image


def create_pixmap_from_svg_bytes(svg_str: bytes) -> QPixmap:
    """There is no QIcon option for loading SVG from a string, only from a SVG file, so roll our own."""
    image = create_image_from_svg_bytes(svg_str)
    return QPixmap.fromImage(image)


def create_icon_from_svg_bytes(default_svg: bytes = None,
                               on_svg: bytes = None, off_svg: bytes = None,
                               disabled_svg: bytes = None) -> QIcon:
    """There is no QIcon option for loading SVG from a string, only from a SVG file, so roll our own."""
    if default_svg is not None:
        icon = QIcon(create_pixmap_from_svg_bytes(default_svg))
    else:
        icon = QIcon()
    if on_svg is not None:
        icon.addPixmap(create_pixmap_from_svg_bytes(on_svg), state=QIcon.On)
    if off_svg is not None:
        icon.addPixmap(create_pixmap_from_svg_bytes(off_svg), state=QIcon.Off)
    if disabled_svg:
        icon = QIcon(create_pixmap_from_svg_bytes(on_svg), mode=QIcon.Disabled)
    return icon


def create_disabled_icon_from_themed_icon(themed_icon):
    pixmap = QPixmap(themed_icon.pixmap(60, 60, QIcon.Disabled, QIcon.Off))
    new_icon = QIcon()
    new_icon.addPixmap(pixmap, state=QIcon.On)
    new_icon.addPixmap(pixmap, state=QIcon.Off)
    new_icon.addPixmap(pixmap, mode=QIcon.Disabled)
    return new_icon


managed_svg_icon_source: Mapping[QObject, str] = weakref.WeakKeyDictionary()
themed_icon_cache: Mapping[Union[str, bytes], QIcon] = {}


def get_themed_icon(source) -> QIcon:
    if source in themed_icon_cache:
        return themed_icon_cache[source]
    if isinstance(source, str):
        return QIcon.fromTheme(source)
    if isinstance(source, bytes):
        return create_icon_from_svg_bytes(source)
    raise ValueError(f"get_icon parameter has unsupported type {type(source)} = {str(source)}")


def manage_icon(q_object: QObject, source: Union[str, bytes]):
    # Hold a weak reference to any item that might need an icon reload on a theme change
    # At the moment this is only applicable to our internal SVG sourced icons.
    icon = get_themed_icon(source)
    managed_svg_icon_source[q_object] = source
    q_object.setIcon(icon)
    return q_object


def apply_icon_theme_change():
    themed_icon_cache.clear()
    for q_object, svg_source in managed_svg_icon_source.items():
        print(q_object.objectName())
        manage_icon(q_object, svg_source)


def big_label(label: QLabel) -> QLabel:
    # Setting the style breaks theme changes, use HTML instead
    # widget.setStyleSheet("QLabel { font-weight: normal;font-size: 12pt; }")
    label.setTextFormat(Qt.TextFormat.AutoText)
    label.setText(f"<b>{label.text()}</b>")
    return label


def transparent_button(button: QPushButton) -> QPushButton:
    button.setStyleSheet("""
        QPushButton { background-color: transparent; border: 0px; width:32px; height:32px; }
        QPushButton:hover { border: 1px solid blue; }
        """)
    button.setIconSize(QSize(24, 24))
    return button


class DockUndockWindow(QMainWindow):
    """
    I wanted an undockable component, but one with normal window decorations.
    So I've taken over the undocking process and reparent the floating window onto
    this alternate main window.
    """

    def __init__(self, panel_container: 'DockContainer'):
        super().__init__(parent=None)
        self.setObjectName('config_main_window')
        self.panel_container = panel_container
        self.previous_geometry = None
        debug("ConfigMainWindow visible", self.isVisible()) if debugging else None

    def hide(self) -> None:
        self.previous_geometry = self.saveGeometry()
        super().hide()

    def show(self) -> None:
        self.restoreGeometry(self.previous_geometry) if self.previous_geometry is not None else None
        super().show()

    def closeEvent(self, close_event: QCloseEvent) -> None:
        close_event.ignore()
        self.hide()
        self.panel_container.dock_to_main_window()


class DockableWidget(QWidget):

    def __init__(self, parent=None, flags=None, *args, **kwargs):
        super().__init__(parent=None, *args, **kwargs)

    def add_dock_control(self, dock_button: QPushButton):
        pass


class DockContainer(QDockWidget):

    def __init__(self, dockable_widget: DockableWidget, home_window: QMainWindow, home_dock_area: Qt.DockWidgetArea):
        super().__init__(parent=None, flags=Qt.WindowFlags(Qt.WindowStaysOnTopHint))
        self.setObjectName(dockable_widget.objectName() + '_dock_container')
        self.window_geometry_key = self.objectName() + '_dock_window_geometry'
        self.window_state_key = self.objectName() + '_dock_window_state'
        self.target_geometry_key = self.objectName() + '_dock_target_geometry'
        self.dock_key = self.objectName() + '_in_home_dock'

        self.target = dockable_widget
        self.previous_home_geometry: Mapping[str, QWidget] = {}

        self.is_docked_to_home = None
        self.home_window = home_window
        self.home_dock_area = home_dock_area

        self.setTitleBarWidget(QWidget())
        self.dock_window = DockUndockWindow(panel_container=self)

        self.setFloating(False)
        self.setFeatures(QDockWidget.DockWidgetFloatable | QDockWidget.DockWidgetMovable)

        self.dock_button = transparent_button(manage_icon(QPushButton('', self), ICON_UNDOCK))
        self.dock_button.setToolTip(tr("Dock/undock this panel"))
        self.dock_button.pressed.connect(self.switch_dock_state)
        self.target.add_dock_control(self.dock_button)

        self.setWidget(self.target)

    def showEvent(self, event: QEvent):
        # debug(event.type(), event) if debugging else None
        super().showEvent(event)
        self.target.setMinimumHeight(0)

    def switch_dock_state(self):
        # Switch between docked and undocked - use the system window manager.
        if self.is_docked_to_home is None or self.is_docked_to_home:
            self.dock_to_dock_window()
        else:
            self.dock_to_main_window()

    def dock_to_main_window(self):
        # debug('dock_main_window') if debugging else None
        self.is_docked_to_home = True
        self.setFloating(True)
        self.dock_window.hide()
        manage_icon(self.dock_button, ICON_UNDOCK)
        if self.previous_home_geometry:
            # Hacky trick to force the panel to restore it's previous size.
            # The minimum be be retracted in showEvent().
            # if self.home_window.height() > self.previous_home_geometry.height():
            self.target.setMinimumHeight(self.previous_home_geometry.height())
        self.home_window.addDockWidget(self.home_dock_area, self)
        self.setFloating(False)
        if self.dock_window.isVisible():
            self.dock_window.hide()

    def dock_to_dock_window(self, show: bool = True):
        # debug('dock_config_window') if debugging else None
        # self.previous_home_geometry = {}
        # for dock_widget in self.home_window.findChildren(QDockWidget):
        #     self.previous_home_geometry[dock_widget.objectName()] = dock_widget.geometry()
        #     debug("saving", dock_widget.objectName(), dock_widget.panel.geometry())
        self.previous_home_geometry = self.target.geometry()
        self.is_docked_to_home = False
        self.setFloating(True)
        manage_icon(self.dock_button, ICON_DOCK)
        self.dock_window.addDockWidget(Qt.DockWidgetArea.TopDockWidgetArea, self)
        self.setFloating(False)
        if show:
            self.dock_window.show()

    def undock_and_show(self):
        if self.is_docked_to_home:
            self.dock_to_dock_window(show=True)
        else:
            self.activate_dock_window()
            self.dock_window.showNormal()

    def activate_dock_window(self):
        if not self.is_docked_to_home:
            self.dock_window.show()
            self.dock_window.raise_()
            self.dock_window.activateWindow()

    def deactivate_dock_window(self):
        if not self.is_docked_to_home:
            self.dock_window.hide()

    def app_save_state(self, to_settings: QSettings):
        to_settings.setValue(self.window_geometry_key, self.dock_window.saveGeometry())
        to_settings.setValue(self.window_state_key, self.dock_window.saveState())
        to_settings.setValue(self.target_geometry_key, self.target.saveGeometry())
        to_settings.setValue(self.dock_key, b'home_window' if self.is_docked_to_home else b'dock_window')

    def app_restore_state(self, from_settings: QSettings, show: bool = True):
        if from_settings.value(self.window_geometry_key) is not None:
            try:
                self.dock_window.restoreGeometry(from_settings.value(self.window_geometry_key))
                self.dock_window.restoreState(from_settings.value(self.window_state_key))
                self.target.restoreGeometry(from_settings.value(self.target_geometry_key))
                # Constrain the height until showEvent() when we will remove the constraint.
                self.target.setMinimumHeight(self.target.geometry().height())
                self.is_docked_to_home = from_settings.value(self.dock_key) == b'home_window'
            except:
                warning("Failed to restore geometry of GUI components.")
        if self.is_docked_to_home is None or self.is_docked_to_home:
            self.dock_to_main_window()
        else:
            self.dock_to_dock_window(show=show)


class ConfigPanel(DockableWidget):
    signal_editing_filter_pattern = pyqtSignal(str, bool)

    def __init__(self, tab_change: Callable, config_change_func: Callable):
        super().__init__(parent=None, flags=Qt.WindowFlags(Qt.WindowStaysOnTopHint))
        self.setObjectName('config-panel-new')

        layout = QVBoxLayout()
        self.setLayout(layout)

        title_container = QWidget(self)
        title_layout = QHBoxLayout()
        self.title_layout = title_layout
        title_container.setLayout(title_layout)
        title_label = big_label(QLabel(tr("Configuration")))
        title_layout.addWidget(title_label)
        spacer = QWidget()
        spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
        title_layout.addWidget(spacer)

        self.restart_is_required = False

        tabs = QTabWidget()
        self.tabs = tabs

        self.config = Config()
        self.config.refresh()

        options_panel = OptionsTab(self.config['options'], parent=self)

        match_panel = FilterPanel(
            self.config['match'],
            tooltip=tr("Only issue notifications for journal-entry messages that match one of these rules."),
            config_panel=self)

        ignore_panel = FilterPanel(
            self.config['ignore'],
            tooltip=tr("Ignore journal-entry messages that match any of these rules."),
            config_panel=self)

        button_box = QWidget()
        button_box_layout = QHBoxLayout()
        button_box.setLayout(button_box_layout)
        apply_button = QPushButton(tr("Apply"))
        apply_button.setToolTip(tr("Apply the config changes from now on - do no reprocess existing messages."))
        manage_icon(apply_button, ICON_APPLY)
        revert_button = QPushButton(tr("Revert"))
        revert_button.setToolTip(tr("Revert the changes made."))
        manage_icon(revert_button, ICON_REVERT)
        apply_and_restart_button = QPushButton(tr("Apply+Reset"))
        apply_and_restart_button.setToolTip(tr("Apply the config changes - re-read and process the entire journal"))
        manage_icon(apply_and_restart_button, ICON_APPLY_AND_RESTART)
        button_box_layout.addWidget(revert_button)
        spacer = QLabel('          ')
        button_box_layout.addWidget(spacer)
        button_box_layout.addWidget(apply_button)
        button_box_layout.addWidget(apply_and_restart_button)

        self.status_bar = StatusBar()
        self.status_bar.addPermanentWidget(button_box)

        def save_action():
            debug("save action") if debugging else None
            try:
                if match_panel.is_valid() and ignore_panel.is_valid():
                    tmp = Config()
                    options_panel.copy_to_config(tmp['options'])
                    match_panel.copy_to_config(tmp['match'])
                    ignore_panel.copy_to_config(tmp['ignore'])
                    if not self.config.is_different(tmp):
                        apply_message = QMessageBox(self)
                        apply_message.setText(tr('There are no changes to apply. Apply and save anyway?'))
                        apply_message.setIcon(QMessageBox.Question)
                        apply_message.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel)
                        if apply_message.exec() == QMessageBox.Cancel:
                            return
                    options_panel.copy_to_config(self.config['options'])
                    match_panel.copy_to_config(self.config['match'])
                    ignore_panel.copy_to_config(self.config['ignore'])
                    self.config.save()
                    match_panel.clear_selection()
                    ignore_panel.clear_selection()
                    self.status_bar.show_info("All changes have been saved.", STATUS_TIMEOUT_MSEC)
                    debug(f'config saved ok') if debugging else None
                    self.restart_is_required = False
            except FilterValidationException as e:
                e_title, summary, text = e.args
                message = QMessageBox(self)
                message.setWindowTitle(e_title)
                message.setText(f"{tr('Cannot apply changes.')}\n{summary}\n{text}")
                message.setIcon(QMessageBox.Critical)
                message.setStandardButtons(QMessageBox.Ok)
                # message.setDetailedText()
                message.exec()

        apply_button.clicked.connect(save_action)

        def apply_and_restart_action():
            save_action()
            self.restart_is_required = True

        apply_and_restart_button.clicked.connect(apply_and_restart_action)

        def revert_action():
            debug("revert") if debugging else None
            tmp = Config()
            options_panel.copy_to_config(tmp['options'])
            match_panel.copy_to_config(tmp['match'])
            ignore_panel.copy_to_config(tmp['ignore'])
            if not self.config.is_different(tmp):
                revert_message = QMessageBox(self)
                revert_message.setText(tr('There are no unapplied changes. There is nothing to revert.'))
                revert_message.setIcon(QMessageBox.Warning)
                revert_message.setStandardButtons(QMessageBox.Ok)
                revert_message.exec()
                return
            else:
                revert_message = QMessageBox(self)
                revert_message.setText(
                    tr("There are changes that haven't been applied. Revert and loose those changes?"))
                revert_message.setIcon(QMessageBox.Question)
                revert_message.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel)
                if revert_message.exec() == QMessageBox.Cancel:
                    return
            info("Reverting unsaved changes.")
            self.status_bar.show_warning("Unapplied changes have been reverted.", STATUS_TIMEOUT_MSEC)
            reload_from_config()

        def reload_from_config():
            info("UI reloading config from file.") if debugging else None
            options_panel.copy_from_config(self.config['options'])
            match_panel.copy_from_config(self.config['match'])
            ignore_panel.copy_from_config(self.config['ignore'])
            match_panel.clear_selection()
            ignore_panel.clear_selection()

        revert_button.clicked.connect(revert_action)

        tabs.addTab(ignore_panel, tr("Ignore Filters"))
        tabs.addTab(match_panel, tr("Match Filters"))
        tabs.addTab(options_panel, tr("Options"))
        tabs.setCurrentIndex(0)

        tabs.setTabToolTip(0, tr("Ignored-messages will be excluded from desktop-notifications."))
        tabs.setTabToolTip(1, tr("Matched-messages will be included in desktop-notifications (excluding all others)."))
        tabs.setTabToolTip(2, tr("Application configuration options."))

        layout.addWidget(title_container)

        layout.addWidget(tabs)
        layout.addWidget(self.status_bar)

        tabs.currentChanged.connect(tab_change)

        self.setWindowTitle(tr("Configuration"))

        reload_from_config()

        self.config_watcher = ConfigWatcherTask(self.config)

        def config_change():
            reload_from_config()
            config_change_func()

        self.config_watcher.signal_config_change.connect(config_change)
        self.config_watcher.start()

    def add_dock_control(self, dock_button: QPushButton):
        self.title_layout.addWidget(dock_button)

    def add_filter(self, suggested_rule_id, pattern) -> None:
        if isinstance(self.tabs.currentWidget(), FilterPanel):
            self.tabs.currentWidget().add_rule(suggested_rule_id, pattern)
        else:
            raise TypeError("Was expecting FilterPanel")

    def delete_filter(self) -> None:
        if isinstance(self.tabs.currentWidget(), FilterPanel):
            self.tabs.currentWidget().delete_rules()
        else:
            raise TypeError("Was expecting FilterPanel")

    def get_config(self) -> Config:
        return self.config

    def requires_restart(self):
        return self.restart_is_required

    def restart_is_completed(self):
        self.restart_is_required = False

class OptionsTab(QWidget):

    def __init__(self, config_section: Mapping[str, str], parent: QWidget = None):
        super().__init__(parent=parent)
        self.option_map: Mapping[str, QWidget] = {}
        grid_layout = QGridLayout(self)
        bool_count = 0
        text_count = 0
        for i, option_spec in enumerate(CONFIG_OPTIONS_LIST):
            col_span = 1
            option_id = option_spec.option_id
            value = config_section[option_id] if option_id in config_section else ''
            label_widget = QLabel(option_spec.label())
            label_widget.setToolTip(option_spec.tooltip())
            if option_id.endswith("_enabled"):
                input_widget = QCheckBox()
                input_widget.setChecked(value == 'yes')
                input_widget.setToolTip(option_spec.tooltip())
                column_number = 3
                row_number = bool_count
                bool_count += 1
            elif option_id.endswith("_list"):
                input_widget = QTextEdit()
                input_widget.setFixedWidth(1000)
                input_widget.setText(value)
                input_widget.setToolTip(option_spec.tooltip())
                column_number = 0
                row_number = max(text_count, bool_count) + 1
                col_span = 4
                text_count += 1
            else:
                input_widget = QLineEdit()
                input_widget.setMaximumWidth(100)
                input_widget.setText(value)
                if option_spec.int_range is not None:
                    input_widget.setValidator(QIntValidator(option_spec.int_range[0], option_spec.int_range[1]))
                else:
                    input_widget.setValidator(QIntValidator(1, 100000))
                input_widget.setToolTip(option_spec.tooltip())
                column_number = 0
                row_number = text_count
                text_count += 1
            grid_layout.addWidget(label_widget, row_number, column_number, 1, 1, alignment=Qt.AlignLeft | Qt.AlignTop)
            grid_layout.addWidget(input_widget, row_number, column_number + 1, 1, col_span,
                                  alignment=Qt.AlignLeft | Qt.AlignTop)
            self.option_map[option_id] = input_widget
            if column_number == 0:
                spacer = QLabel("\u2003\u2003")
                grid_layout.addWidget(spacer, row_number, 2)
        grid_layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize)
        scroll_area = QScrollArea(self)
        container = QWidget(scroll_area)
        container.setLayout(grid_layout)
        scroll_area.setWidget(container)
        layout = QVBoxLayout()
        layout.addWidget(scroll_area)
        grid_layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize)
        grid_layout.setHorizontalSpacing(20)
        self.setLayout(layout)

    def copy_from_config(self, config_section: Mapping[str, str]):
        for option_id, widget in self.option_map.items():
            if option_id in config_section:
                if option_id.endswith("_enabled"):
                    widget.setChecked(config_section[option_id].lower() == "yes")
                else:
                    widget.setText(config_section[option_id])

    def copy_to_config(self, config_section: Mapping[str, str]):
        for option_id, widget in self.option_map.items():
            if option_id.endswith("_enabled"):
                config_section[option_id] = "yes" if widget.isChecked() else "no"
            elif option_id.endswith("_list"):
                config_section[option_id] = widget.document().toRawText()
            else:
                if widget.text().strip() != "":
                    config_section[option_id] = widget.text()


class FilterPanel(QWidget):

    def __init__(self, config_section: Mapping[str, str], tooltip: str, config_panel: ConfigPanel):
        super().__init__(parent=config_panel)
        debug("table", str(config_section.keys())) if debugging else None
        self.table_view = FilterTableView(config_section, tooltip, config_panel)
        layout = QVBoxLayout(self)
        layout.addWidget(self.table_view)
        self.setLayout(layout)

    def is_valid(self):
        return self.table_view.is_valid()

    def copy_from_config(self, config_section: Mapping[str, str]):
        self.table_view.copy_from_config(config_section)

    def copy_to_config(self, config_section: Mapping[str, str]):
        self.table_view.copy_to_config(config_section)

    def clear_selection(self):
        self.table_view.clearSelection()

    def add_rule(self, suggested_rule_id: str = '', pattern: str = ''):
        self.table_view.add_new_rule(suggested_rule_id, pattern)

    def delete_rules(self):
        self.table_view.delete_selected_rules()


class FilterTableModel(QStandardItemModel):

    def __init__(self, number_of_rows: int):
        super().__init__(number_of_rows, 2)
        # use spaces to force a wider column - seems to be no other EASY way to do this.
        self.setHorizontalHeaderLabels(
            [tr("Rule-ID (enabled/disabled)"), tr("Pattern (regexp/text)")])
        self.horizontalHeaderItem(0).setToolTip(
            tr("Rule ID: a letter followed by letters, digits, underscores and hyphens") + "\n" +
            tr("Tick/untick the Rule-ID's checkbox to enable/disable this rule."))
        self.horizontalHeaderItem(1).setToolTip(
            tr("Pattern: Text or regexp to partially match in the journal entry") + "\n" +
            tr("Tick the Pattern's checkbox if this pattern is a regular expression.")
        )


class FilterValidationException(Exception):
    pass


class FilterPatternEntryDelegate(QStyledItemDelegate):

    def __init__(self, model: FilterTableModel, config_panel: 'ConfigPanel'):
        super().__init__(model)
        self.model = model
        self.config_panel = config_panel
        self.line_edit = None

    def createEditor(self, parent, option, index):
        self.line_edit = QLineEdit(parent)

        def text_changed(pattern: str):
            # Ask the JournalPanel to select/highlight partial matches as the user types.
            pattern = pattern.strip()
            pattern_is_regexp = self.model.itemFromIndex(index).checkState()
            try:
                if pattern_is_regexp:
                    re.compile(pattern, flags=re.DOTALL)
                self.config_panel.signal_editing_filter_pattern.emit(pattern, pattern_is_regexp)
            except re.error as e:
                self.config_panel.status_bar.show_error(str(e), STATUS_TIMEOUT_MSEC)

        self.line_edit.textEdited.connect(text_changed)
        return self.line_edit


class FilterTableView(QTableView):

    def __init__(self, config_section: Mapping[str, str], tooltip: str, config_panel: ConfigPanel = None):
        super().__init__()
        self.setModel(FilterTableModel(len(config_section)))
        self.copy_from_config(config_section)
        self.setEditTriggers(QAbstractItemView.AllEditTriggers)
        self.verticalHeader().setSectionsMovable(True)
        self.verticalHeader().setDragEnabled(True)
        self.verticalHeader().setDragDropMode(QAbstractItemView.InternalMove)
        self.setDragDropOverwriteMode(True)
        self.resizeColumnsToContents()
        self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
        self.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
        self.horizontalHeader().setDefaultAlignment(Qt.AlignLeft)
        self.setShowGrid(False)
        self.setItemDelegateForColumn(1, FilterPatternEntryDelegate(self.model(), config_panel))

    def item_view_order(self) -> List[int]:
        """
        Walk the table model's rows in model-order of 1..n, find the current y-location or each row,
        sort the y-locations to determine the current view ordering of the model's rows (which may
        no longer be 1..n due to drag and drop).  Return a list of the current view ordering, for
        example [4, 0, 1, 2, 3].
        """
        # If there is no access to the rowCount, rowViewportPosition() can be called
        # until it returns -1 (note it can return other valid negative values, so just test
        # for -1.
        row_y_positions = []
        debug(f"row count={self.model().rowCount()}") if debugging else None
        for row_num in range(self.model().rowCount()):
            y = self.rowViewportPosition(row_num)
            row_y_positions.append((y, row_num))
        row_y_positions.sort()
        return [row_num for _, row_num in row_y_positions]

    def create_rule_id_item(self, rule_id: str):
        rule_id_item = QStandardItem(rule_id)
        rule_id_item.setCheckable(True)
        rule_id_item.setCheckState(Qt.Checked)
        rule_id_item.setEditable(True)
        rule_id_item.setToolTip(self.model().horizontalHeaderItem(0).toolTip())
        return rule_id_item

    def create_pattern_item(self, pattern: str):
        pattern_item = QStandardItem(pattern)
        pattern_item.setCheckable(True)
        pattern_item.setCheckState(Qt.Unchecked)
        pattern_item.setEditable(True)
        pattern_item.setToolTip(self.model().horizontalHeaderItem(1).toolTip())
        return pattern_item

    def is_existing_rule_id(self, id: str):
        model = self.model()
        for row_num in self.item_view_order():
            row_id = row_num + 1
            key = model.item(row_num, 0).text()
            if key == id:
                return True
        return False

    def choose_rule_name(self, starting_text: str):
        if len(starting_text) > 0:
            prefix = re.sub(r'\W+', '_', starting_text)[0:21]
            last_underscore = prefix.rfind('_')
            if last_underscore > 10:
                prefix = prefix[0:last_underscore]
        else:
            prefix = 'rule'
        i = 0
        possible_name = prefix
        while True:
            if self.is_existing_rule_id(possible_name):
                i += 1
                possible_name = prefix + '_' + str(i)
            else:
                return possible_name

    def is_valid(self) -> bool:
        model = self.model()
        seen = []
        for row_num in self.item_view_order():
            row_id = row_num + 1
            key = model.item(row_num, 0).text()
            if key.endswith('_enabled'):
                raise FilterValidationException(
                    self.__class__.__name__,
                    tr("Row {row}.  Invalid ID '{key}'").format(row=row_id, key=key),
                    tr("ID ends in reserved suffix '_enabled'"))
            value = model.item(row_num, 1).text()
            value_is_regexp = model.item(row_num, 1).checkState()
            if re.fullmatch("[a-zA-Z]([a-zA-Z0-9_-])*", key) is None:
                raise FilterValidationException(
                    self.__class__.__name__,
                    tr("Row {row}.  Invalid ID '{key}'").format(row=row_id, key=key),
                    tr("ID's should start with a letter and consist of letters, digits, underscores and hypens only."))
            elif key in seen:
                raise FilterValidationException(
                    self.__class__.__name__,
                    tr("Row {row}.  Invalid ID '{key}'").format(row=row_id, key=key),
                    tr("ID's in must be unique within their own filter-tab."))
            elif value_is_regexp:
                try:
                    re.compile(value, flags=re.DOTALL)
                except re.error as e:
                    raise FilterValidationException(
                        self.__class__.__name__,
                        tr("Row {row}.  Invalid Regular Expression\nID='{key}'\nRegexp='{value}'").format(
                            row=row_id, key=key, value=value),
                        f"{str(e)}")
            seen.append(key)
        return True

    def copy_from_config(self, config_section: Mapping[str, str]):
        model = self.model()
        if model.rowCount() > 0:
            model.removeRows(0, model.rowCount())
        row = 0
        # Step one - first gather the patterns and create a row for each one
        for key, value in config_section.items():
            if key.endswith("_enabled"):
                pass
            else:
                rule_id_item = self.create_rule_id_item(key)
                key_enabled = key + "_enabled"
                if key_enabled in config_section:
                    if config_section[key_enabled].strip().lower() != 'yes':
                        rule_id_item.setCheckState(Qt.Unchecked)
                re_flag = key + "_regexp_enabled"
                model.setItem(row, 0, rule_id_item)
                pattern_item = self.create_pattern_item(value)
                if re_flag in config_section:
                    if config_section[re_flag].strip().lower() == 'yes':
                        pattern_item.setCheckState(Qt.Checked)
                model.setItem(row, 1, QStandardItem(pattern_item))
                row += 1

    def copy_to_config(self, config_section: Mapping[str, str]):
        debug(f'table order = {self.item_view_order()} ') if debugging else None
        for key in config_section.keys():
            del config_section[key]
        model = self.model()
        for row_num in self.item_view_order():
            key = model.item(row_num, 0).text()
            if key.strip() == '':
                continue
            value = model.item(row_num, 1).text()
            config_section[key] = value
            if model.item(row_num, 0).checkState() == Qt.Unchecked:
                config_section[key + "_enabled"] = "no"
            if model.item(row_num, 1).checkState() == Qt.Checked:
                config_section[key + "_regexp_enabled"] = "yes"

    def add_new_rule(self, suggested_rule_id: str = '', pattern: str = ''):
        rule_id = self.choose_rule_name(suggested_rule_id)
        model = self.model()
        selected_row_indices = self.selectionModel().selectedRows()
        if len(selected_row_indices) > 0:
            index = sorted(selected_row_indices)[0]
            model.insertRow(index.row(), [self.create_rule_id_item(rule_id), self.create_pattern_item(pattern)])
            self.scrollTo(index)
            self.clearSelection()
            self.selectRow(index.row())
        else:
            model.appendRow([self.create_rule_id_item(rule_id), self.create_pattern_item(pattern)])
            self.scrollToBottom()
            self.selectRow(model.rowCount() - 1)

    def delete_selected_rules(self):
        model = self.model()
        selected_row_indices = self.selectionModel().selectedRows()
        if len(selected_row_indices) == 0:
            message = QMessageBox(self)
            message.setWindowTitle(tr('Delete'))
            message.setText(
                tr("Cannot delete, no rows selected.\nClick in the left margin to select some rows."))
            message.setIcon(QMessageBox.Critical)
            message.setStandardButtons(QMessageBox.Ok)
            message.exec()
            return
        # Reverse the order so we delete from bottom up preserving the positions of yet to be removed rows.
        for index in sorted(selected_row_indices, reverse=True):
            model.removeRow(index.row())
            if model.rowCount() > index.row():
                self.selectRow(model.rowCount())
            else:
                self.selectRow(model.rowCount() - 1)


class ConfigWatcherTask(QThread):
    signal_config_change = pyqtSignal()

    def __init__(self, config: Config) -> None:
        super().__init__()
        self.config = config

    def run(self) -> None:
        while True:
            if self.config.refresh():
                debug("ConfigWatcherTask - Config Changed") if debugging else None
                self.signal_config_change.emit()
            time.sleep(5.0)


class JournalWatcherTask(QThread):
    signal_historical_entries = pyqtSignal(list)
    signal_historical_progress = pyqtSignal(int)
    signal_new_entry = pyqtSignal(dict, bool)
    signal_error = pyqtSignal(str, Exception)

    def __init__(self) -> None:
        super().__init__()
        self.watcher = JournalWatcher(self)

    def is_notifying(self) -> bool:
        return self.watcher.is_notifying()

    def enable_notifications(self, enable: bool):
        self.watcher.enable_notifications(enable)

    def enable_forward_all(self, enable: bool):
        self.watcher.enable_forward_all(enable)

    def reset(self):
        self.watcher.deliver_history = True

    def run(self) -> None:
        self.watcher.watch_journal()

    def new_journal_entry(self, journal_entry: Mapping, notable: bool):
        self.signal_new_entry.emit(journal_entry, notable)

    def deliver_historical_entries(self, history_list: List):
        self.signal_historical_entries.emit(history_list)

    def report_historical_progress(self, count: int):
        self.signal_historical_progress.emit(count)


class SessionLogForwarder:
    def __init__(self, main_window: QMainWindow):
        self.main_window = main_window
        self.forwarder: ForwardFileTask = None

    def enable(self, enable: bool):
        if enable:
            if self.forwarder is not None and self.forwarder.isRunning():
                return
            search_path_list = [Path.home().joinpath('.local/share/sddm'), Path.home().joinpath('.local/share/gdm'),
                                Path('/var/log/gdm')]
            filename_list = ['xorg-session.log', 'wayland-session.log']
            session_file = find_most_recent_file(filename_list, search_path_list)
            if session_file is None:
                error_message = QMessageBox(self.main_window)
                error_message.setText(tr("Cannot forward {} to systemd journal.").format(str[filename_list]))
                error_message.setDetailedText(
                    tr('Failed to find {} in {}').format(str[filename_list], [str(x) for x in search_path_list]))
                error_message.setIcon(QMessageBox.Question)
                error_message.setStandardButtons(QMessageBox.Ok)
                error_message.setIcon(QMessageBox.Critical)
                error_message.exec()
                return
            self.forwarder = ForwardFileTask(session_file, tail_only=True)
            self.forwarder.start()
            self.main_window.statusBar().showMessage(tr("Forwarding {} to systemd-journal").format(session_file), 30000)
        else:
            if self.forwarder is not None and self.forwarder.isRunning():
                self.forwarder.stop = True
                self.main_window.statusBar().showMessage(tr("Stopped forwarding xorg-session"), 10000)


class MainToolBar(QToolBar):

    def __init__(self,
                 run_func: Callable, notify_func: Callable,
                 add_func: Callable, del_func: Callable, settings_func: Callable,
                 journal_viewer_func: Callable,
                 menu: QMenu,
                 parent: QMainWindow):
        super().__init__(parent=parent)

        # TODO figure out why this toolbar no longer has an undocking handle.
        debug("Toolbar floatable", self.isFloatable(), "movable", self.isMovable()) if debugging else None

        self.setObjectName("main-tool-bar")
        self.setIconSize(QSize(32, 32))
        self.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
        self.setFloatable(False)
        self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)

        self.run_action = manage_icon(self.addAction("run", run_func), SVG_TOOLBAR_RUN_ENABLED)
        self.run_action.setObjectName("run_button")
        self.run_action.setToolTip(tr("Start/stop monitoring the journal feed."))
        # Stylesheets prevent theme changes for the widget - cannot be used.
        # self.widgetForAction(self.run_action).setStyleSheet("QToolButton { width: 130px; }")

        self.stop_action = manage_icon(self.addAction(tr("Stop"), run_func), SVG_TOOLBAR_STOP)
        self.stop_action.setToolTip(tr("Stop monitoring the journal feed."))

        self.addSeparator()

        self.notifier_action = manage_icon(self.addAction("notify", notify_func), SVG_TOOLBAR_NOTIFIER_ENABLED)
        self.notifier_action.setToolTip(tr("Enable/disable desktop-notification forwarding."))
        # Stylesheets prevent theme changes for the widget - cannot be used.
        # self.widgetForAction(self.notifier_action).setStyleSheet("QToolButton { width: 130px; }")

        self.addSeparator()

        self.add_filter_action = manage_icon(self.addAction("add", add_func), SVG_TOOLBAR_ADD_FILTER)
        self.add_filter_action.setObjectName("add_button")
        self.add_filter_action.setIconText(tr("New filter"))
        self.add_filter_action.setToolTip(
            tr("Add a new filter.") + "\n" +
            tr("  1. Select the Ignore-Filters or Match-Filters tab.") + "\n" +
            tr("     a) Optionally select a journal-entry as a basis for the new filter.") + "\n" +
            tr("     b) Optionally click on an existing filter to select an insertion point.") + "\n" +
            tr("  2. Press the New-filter button to begin editing the new filter.") + "\n" +
            tr("  3. Press the Apply button to save and apply the changes.")
        )

        self.del_filter_action = manage_icon(self.addAction("del", del_func), SVG_TOOLBAR_DEL_FILTER)
        self.del_filter_action.setObjectName("del_button")
        self.del_filter_action.setIconText(tr("Delete filter"))
        self.del_filter_action.setToolTip(
            tr("Delete selected filter.") + "\n" +
            tr("  1. Select the Ignore-Filters or Match-Filters tab.") + "\n" +
            tr("  2. Click on a filter to select it for deletion.") + "\n" +
            tr("  3. Press the Delete-filter button.") + "\n" +
            tr("  4. Press the Apply button to save and apply the changes.")
        )

        spacer = QWidget()
        spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
        self.addWidget(spacer)

        self.qurry_journal_action = manage_icon(self.addAction("query", journal_viewer_func), SVG_TOOLBAR_QUERY_JOURNAL)
        self.qurry_journal_action.setObjectName("journal_button")
        self.qurry_journal_action.setIconText(tr("Journal query"))
        self.qurry_journal_action.setToolTip(tr("View/search entire journal."))

        self.addSeparator()
        self.settings_action = manage_icon(self.addAction("settings", settings_func), ICON_SETTINGS_CONFIGURE)
        self.settings_action.setObjectName("settings_button")
        self.settings_action.setIconText(tr("Settings"))
        self.settings_action.setToolTip(tr("Edit settings"))

        self.addSeparator()
        manage_icon(self.addAction(tr('Help'), HelpDialog.invoke), ICON_HELP_CONTENTS)
        manage_icon(self.addAction(tr('About'), AboutDialog.invoke), ICON_HELP_ABOUT)
        self.menu_button = QToolButton(self)
        manage_icon(self.menu_button, SVG_TOOLBAR_HAMBURGER_MENU)
        self.menu_button.setMenu(menu)
        self.menu_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
        self.addWidget(self.menu_button)
        self.installEventFilter(self)

    def configure_run_action(self, running: bool) -> None:
        debug("Run Style is dark", is_dark_theme()) if debugging else None
        if running:
            manage_icon(self.run_action, SVG_TOOLBAR_RUN_ENABLED)
            self.run_action.setIconText(tr("Running"))
            self.stop_action.setEnabled(True)
        else:
            manage_icon(self.run_action, SVG_TOOLBAR_RUN_DISABLED)
            self.run_action.setIconText(tr("Stopped"))
            self.stop_action.setEnabled(False)

    def configure_notifier_action(self, notifying: bool) -> None:
        padded = pad_text([tr('Notifying'), tr('Mute')])
        if notifying:
            manage_icon(self.notifier_action, SVG_TOOLBAR_NOTIFIER_ENABLED)
            # self.notifier_action.setIconText(tr("Notifying"))
            self.notifier_action.setIconText(padded[0])
        else:
            manage_icon(self.notifier_action, SVG_TOOLBAR_NOTIFIER_DISABLED)
            # Don't do this with a style sheet - style sheets will break dark/light theme loading.
            # self.notifier_action.setIconText(tr("Mute   \u2002"))
            self.notifier_action.setIconText(padded[1])

    def configure_filter_actions(self, enable: bool) -> None:
        self.add_filter_action.setEnabled(enable)
        self.del_filter_action.setEnabled(enable)


def pad_text(text_list: List[str]):
    max_width = 0
    width_list = []
    output_list = []
    for text in text_list:
        tmp = QLabel(text)
        tmp.adjustSize()
        width = tmp.fontMetrics().boundingRect(tmp.text()).width()
        if width > max_width:
            # debug(f"text='{text}' New max='{width}'")
            max_width = width
        width_list.append(width)
    for text, width in zip(text_list, width_list):
        if width < max_width:
            space = '\u2002'
            while True:
                spaced = text + space
                tmp2 = QLabel(spaced)
                spaced_width = tmp2.fontMetrics().boundingRect(tmp2.text()).width()
                if spaced_width > max_width:
                    break
                # debug(f"text='{text}' w={spaced_width} max={max_width}")
                text = spaced
        output_list.append(text)
    return output_list


class MainContextMenu(QMenu):

    def __init__(self, run_func: Callable, notify_func: Callable, quit_func: Callable,
                 query_journal_func: Callable, settings_func: Callable,
                 parent: 'MainWindow'):
        super().__init__(parent=parent)
        self.listen_action = manage_icon(self.addAction(tr("Stop journal monitoring"), run_func),
                                         ICON_CONTEXT_MENU_LISTENING_DISABLE)
        self.notifier_action = manage_icon(self.addAction(tr("Disable notifications"), notify_func),
                                           SVG_TOOLBAR_NOTIFIER_ENABLED)
        self.addSeparator()
        manage_icon(self.addAction(tr('Journal query'), query_journal_func), SVG_TOOLBAR_QUERY_JOURNAL)
        self.addSeparator()
        manage_icon(self.addAction(tr("Settings"), settings_func), ICON_SETTINGS_CONFIGURE)
        self.addSeparator()
        manage_icon(self.addAction(tr('Help'), HelpDialog.invoke), ICON_HELP_CONTENTS)
        manage_icon(self.addAction(tr('About'), AboutDialog.invoke), ICON_HELP_ABOUT)
        self.addSeparator()
        manage_icon(self.addAction(tr('Quit'), quit_func), ICON_APPLICATION_EXIT)

    def configure_run_action(self, running: bool) -> None:
        if running:
            self.listen_action.setText(tr("Stop journal monitoring"))
            manage_icon(self.listen_action, ICON_CONTEXT_MENU_LISTENING_DISABLE)
        else:
            self.listen_action.setText(tr("Resume journal monitoring"))
            manage_icon(self.listen_action, ICON_CONTEXT_MENU_LISTENING_ENABLE)

    def configure_notifier_action(self, notifying: bool) -> None:
        if notifying:
            self.notifier_action.setText(tr("Disable notifications"))
            manage_icon(self.notifier_action, SVG_TOOLBAR_NOTIFIER_DISABLED)
        else:
            self.notifier_action.setText(tr("Enable notifications"))
            manage_icon(self.notifier_action, SVG_TOOLBAR_NOTIFIER_ENABLED)


class StatusBar(QStatusBar):

    next_clock_face = {"\u25f4": "\u25f7", "\u25f7": "\u25f6", "\u25f6": "\u25f5", "\u25f5": "\u25f4"}

    def __init__(self, parent: typing.Optional[QWidget] = None):
        super().__init__(parent)
        self.clock_face = "\u25f4"

    def show_info(self, message: str, msecs: int = 0) -> None:
        super().showMessage(f"\U0001F6C8 {message}", msecs)

    def show_warning(self, message: str, msecs: int = 0) -> None:
        super().showMessage(f"\U0001F6C6 {message}", msecs)

    def show_error(self, message: str, msecs: int = 0) -> None:
        super().showMessage(f"\U0001F6C7 {message}", msecs)

    def show_progress(self, message: str, msecs: int = 0) -> None:
        self.clock_face = StatusBar.next_clock_face[self.clock_face]
        super().showMessage(f"{self.clock_face} {message}", msecs)


wait_for_system_tray = True


def is_system_tray_available():
    # Only wait the first time called
    global wait_for_system_tray
    if wait_for_system_tray:
        if not QSystemTrayIcon.isSystemTrayAvailable():
            print("WARNING: no system tray, waiting to see if one becomes available.")
            for i in range(0, SYSTEM_TRAY_WAIT_SECONDS):
                if QSystemTrayIcon.isSystemTrayAvailable():
                    break
                time.sleep(1)
    wait_for_system_tray = False
    return QSystemTrayIcon.isSystemTrayAvailable()


is_wayland = False


class MainWindow(QMainWindow):

    def __init__(self, app: QApplication):
        super().__init__()

        global debugging
        self.setObjectName('main_window')
        self.geometry_key = self.objectName() + "_geometry"
        self.state_key = self.objectName() + "_window_state"

        journal_watcher_task = JournalWatcherTask()
        info('QStyleFactory.keys()=', QStyleFactory.keys())
        info(f"Icon theme path={QIcon.themeSearchPaths()}")
        info(f"Icon theme '{QIcon.themeName()}' >> is_dark_theme()={is_dark_theme()}")

        QGuiApplication.setDesktopFileName('jouno')

        global is_wayland
        is_wayland = QGuiApplication.platformName() == "wayland"
        debug("is_wayland", is_wayland) if debugging else None

        app_name = tr('Jouno')
        app.setWindowIcon(get_themed_icon(SVG_JOUNO_LIGHT))
        app.setApplicationDisplayName(app_name)
        app.setApplicationVersion(JOUNO_VERSION)
        # Make sure all icons use HiDPI - toolbars don't by default, so force it.
        app.setAttribute(Qt.AA_UseHighDpiPixmaps)

        xorg_session_forwarder = SessionLogForwarder(self)

        self.settings = QSettings('jouno.qt.state', 'jouno')

        def update_title_and_tray_indicators() -> None:
            if journal_watcher_task.isRunning():
                title_text = tr("Running") if journal_watcher_task.is_notifying() else tr("Muted")
                self.setWindowTitle(title_text)
                tray.setToolTip(f"{title_text} \u2014 {app_name}")
                print(app.styleSheet())
                manage_icon(tray,
                            SVG_JOUNO_LIGHT if config_panel.get_config().getboolean('options',
                                                                                    'dark_tray_enabled',
                                                                                    fallback=False) else SVG_JOUNO)
            else:
                title_text = tr("Stopped")
                self.setWindowTitle(title_text)
                tray.setToolTip(f"{title_text} \u2014 {app_name}")
                manage_icon(tray, ICON_TRAY_LISTENING_DISABLED)

        def enable_listener(enable: bool) -> None:
            if enable:
                journal_watcher_task.start()
                while not journal_watcher_task.isRunning():
                    time.sleep(0.2)
            else:
                journal_watcher_task.requestInterruption()
                while journal_watcher_task.isRunning():
                    time.sleep(0.2)
            tool_bar.configure_run_action(enable)
            app_context_menu.configure_run_action(enable)
            update_title_and_tray_indicators()

        def toggle_listener() -> None:
            enable_listener(not journal_watcher_task.isRunning())

        def enable_notifier(enable: bool) -> None:
            journal_watcher_task.enable_notifications(enable)
            tool_bar.configure_notifier_action(enable)
            app_context_menu.configure_notifier_action(enable)
            update_title_and_tray_indicators()

        def toggle_notifier() -> None:
            enable_notifier(not journal_watcher_task.is_notifying())

        def quit_app() -> None:
            journal_watcher_task.requestInterruption()
            self.app_save_state()
            app.quit()

        def tab_change(tab_number) -> None:
            tool_bar.configure_filter_actions(tab_number == 0 or tab_number == 1)

        def config_change() -> None:
            journal_panel.set_max_entries(config_panel.get_config().getint('options', 'journal_history_max'))
            global debugging
            debugging = config_panel.get_config().getboolean('options', 'debug_enabled')
            self.config_panel.status_bar.show_info(tr("Applying configuration changes."), STATUS_SHORT_TIMEOUT_MSEC)
            self.journal_panel.static_status_label.setText("")
            xorg_session_forwarder.enable(
                config_panel.get_config().getboolean('options', 'forward_session_log_enabled', fallback=False))
            if self.use_system_tray():
                if not tray.isVisible():
                    tray.setVisible(True)
            else:
                if tray.isVisible():
                    tray.setVisible(False)
            if config_panel.requires_restart():
                is_running = journal_watcher_task.isRunning()
                if is_running:
                    self.journal_panel.journal_status_bar.show_progress(tr("Restarting..."))
                    QApplication.processEvents()
                    enable_listener(False)
                journal_watcher_task.reset()
                self.journal_panel.journal_status_bar.show_progress(tr("Clearing existing entries..."))
                QApplication.processEvents()
                journal_panel.clear_all_entries()
                config_panel.restart_is_completed()
                if is_running:
                    enable_listener(True)
                self.journal_panel.journal_status_bar.show_info(tr("Reset completed."), msecs=STATUS_SHORT_TIMEOUT_MSEC)
                QApplication.processEvents()

        def add_filter() -> None:
            journal_entry = journal_panel.get_selected_journal_entry()
            if journal_entry is None:
                suggested_rule_id = ''
            else:
                suggested_rule_id = journal_entry['MESSAGE'] if 'MESSAGE' in journal_entry else ''
            config_panel.add_filter(suggested_rule_id, suggested_rule_id)

        def delete_filter() -> None:
            config_panel.delete_filter()

        def query_journal() -> None:
            QueryInitializeWidget(parent=self)

        def settings_edit() -> None:
            self.config_dock_container.undock_and_show()

        self.config_panel = config_panel = ConfigPanel(tab_change=tab_change, config_change_func=config_change)
        self.config_dock_container = DockContainer(
            dockable_widget=config_panel, home_window=self, home_dock_area=Qt.DockWidgetArea.BottomDockWidgetArea)

        debugging = config_panel.get_config().getboolean('options', 'debug_enabled')

        self.journal_panel = journal_panel = JournalPanel(
            max_entries=config_panel.get_config().getint('options', 'journal_history_max'))
        self.journal_dock_container = DockContainer(
            dockable_widget=journal_panel, home_window=self, home_dock_area=Qt.DockWidgetArea.TopDockWidgetArea)

        def new_journal_entry(entry, notable: bool):
            self.journal_panel.new_journal_entry(entry, notable)

        def process_historical_entries(historical_entries: List[Tuple]):
            self.journal_panel.journal_status_bar.show_info(tr("Initialising..."))
            for entry, notable in historical_entries:
                self.journal_panel.add_journal_entry(entry, notable)
            self.journal_panel.journal_status_bar.showMessage('')
            # Scroll to bottom to await new entries
            self.journal_panel.new_journal_entry(None, False)

        def process_progress(count: int):
            self.journal_panel.journal_status_bar.show_progress(tr("Scanned {} entries").format(count))
            #QApplication.processEvents()

        def handle_watcher_error(error_str: str, e: Exception):
            msg = QMessageBox(self)
            msg.setWindowTitle(tr("Error"))
            msg.setText(tr(error_str))
            msg.setDetailedText(str(e))
            msg.setIcon(QMessageBox.Critical)
            msg.setStandardButtons(QMessageBox.Ok)
            msg.exec()
            if error_str == ERROR_DBUS_NOTIFICATIONS_UNAVAILABLE:
                enable_notifier(False)

        journal_watcher_task.signal_error.connect(handle_watcher_error)
        journal_watcher_task.signal_new_entry.connect(new_journal_entry)
        journal_watcher_task.signal_historical_entries.connect(process_historical_entries)
        journal_watcher_task.signal_historical_progress.connect(process_progress)

        self.config_panel.signal_editing_filter_pattern.connect(journal_panel.search_select_journal)

        app_context_menu = MainContextMenu(
            run_func=toggle_listener, notify_func=toggle_notifier, quit_func=quit_app,
            query_journal_func=query_journal, settings_func=settings_edit,
            parent=self)

        tool_bar = MainToolBar(
            run_func=toggle_listener, notify_func=toggle_notifier,
            add_func=add_filter, del_func=delete_filter, journal_viewer_func=query_journal, settings_func=settings_edit,
            menu=app_context_menu,
            parent=self)
        self.tool_bar = tool_bar
        self.addToolBar(tool_bar)

        tray = QSystemTrayIcon()
        manage_icon(tray,
                    SVG_JOUNO_LIGHT if config_panel.get_config().getboolean('options',
                                                                            'dark_tray_enabled',
                                                                            fallback=False) else SVG_JOUNO)
        tray.setContextMenu(app_context_menu)

        tray.activated.connect(self.tray_activate_window)
        if self.use_system_tray():
            tray.setVisible(True)
        else:
            self.show()

        enable_listener(True)
        enable_notifier(config_panel.get_config().getboolean('options', 'start_with_notifications_enabled'))

        xorg_session_forwarder.enable(
            config_panel.get_config().getboolean('options', 'forward_session_log_enabled', fallback=False))

        if len(self.settings.allKeys()) == 0:
            # First run or qt settings have been erased - guess at sizes and locations
            rec = QApplication.desktop().screenGeometry()
            x = int(rec.width())
            y = int(rec.height())
            self.setGeometry(x // 2 - 100, y // 3, x // 3, y // 2)
            self.journal_dock_container.setGeometry(x // 2 - 150 - x // 3, y // 3, x // 3, y // 2)
            self.config_dock_container.setGeometry(x // 2 - 150 - 2 * x // 3, y // 3, x // 3, y // 2)
        self.app_restore_state()

        try:
            if grp.getgrnam('systemd-journal').gr_gid not in os.getgroups():
                self.statusBar().showMessage(
                    tr("** To see all messages please get yourself added to the Linux systemd-journald group. **"),
                    30000)
        except KeyError as e:
            self.statusBar().showMessage(
                tr("This system lacks a systemd-journal group, normally it should have one."), 10000)

        rc = app.exec_()
        if rc == 999:  # EXIT_CODE_FOR_RESTART:
            QProcess.startDetached(app.arguments()[0], app.arguments()[1:])
        sys.exit(rc)

    def event(self, event: 'QEvent') -> bool:
        try:
            return super().event(event)
        finally:
            # ApplicationPaletteChange happens after the new style theme is in use.
            if event.type() == QEvent.ApplicationPaletteChange:
                debug(f"ApplicationPaletteChange is_dark_theme() {is_dark_theme()}") if debugging else None
                apply_icon_theme_change()

    def closeEvent(self, event: QCloseEvent) -> None:
        debug("closeEvent") if debugging else None
        if self.use_system_tray():
            self.tray_activate_window()
        else:
            self.app_save_state()
        super().closeEvent(event)

    def tray_activate_window(self):
        if self.isVisible():
            debug("tray_activate_window hide") if debugging else None
            self.hide()
            self.journal_dock_container.deactivate_dock_window()
            self.config_dock_container.deactivate_dock_window()
        else:
            debug("tray_activate_window show") if debugging else None
            self.show()
            # Attempt to force it to the top with raise and activate
            self.raise_()
            self.activateWindow()
            self.journal_dock_container.activate_dock_window()
            self.config_dock_container.activate_dock_window()

    def use_system_tray(self):
        return is_system_tray_available() and \
               self.config_panel.get_config().getboolean('options', 'system_tray_enabled')

    def app_save_state(self):
        debug(f"app_save_state {self.geometry_key} {self.state_key}") if debugging else None
        self.settings.setValue(self.geometry_key, self.saveGeometry())
        self.settings.setValue(self.state_key, self.saveState())
        self.journal_dock_container.app_save_state(to_settings=self.settings)
        self.config_dock_container.app_save_state(to_settings=self.settings)

    def app_restore_state(self):
        debug("app_restore_state") if debugging else None
        geometry = self.settings.value(self.geometry_key, None)
        if geometry is not None:
            debug(f"Restore {self.geometry_key} {self.state_key}") if debugging else None
            self.restoreGeometry(geometry)
            window_state = self.settings.value(self.state_key, None)
            self.restoreState(window_state)
        system_tray_in_use = self.use_system_tray()
        self.journal_dock_container.app_restore_state(from_settings=self.settings, show=not system_tray_in_use)
        self.config_dock_container.app_restore_state(from_settings=self.settings, show=not system_tray_in_use)
        if self.tool_bar.isHidden():
            self.tool_bar.setVisible(True)


class JournalPanel(DockableWidget):

    def __init__(self, max_entries: int):
        super().__init__(parent=None, flags=Qt.WindowFlags(Qt.WindowStaysOnTopHint))
        self.setObjectName("journal-panel")

        self.table_view = JournalTableView()
        self.table_view.model().set_max_entries(max_entries)

        self.listening_for_new_entries = False

        layout = QVBoxLayout()
        self.setLayout(layout)

        title_container = QWidget(self)
        title_layout = QHBoxLayout()
        title_container.setLayout(title_layout)
        self.title_label = QLabel(tr("Recently Notified"))
        title_label = big_label(self.title_label)
        title_layout.addWidget(title_label)

        spacer = QWidget()
        spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
        title_layout.addWidget(spacer)

        self.re_search_enabled = False

        def search_entries(text: str) -> None:
            self.scrolled_to_selected = None
            if self.re_search_enabled:
                try:
                    re.compile(text, flags=re.DOTALL)
                except re.error as e:
                    self.journal_status_bar.show_error(str(e))
                    return
            self.search_select_journal(text, regexp_search=self.re_search_enabled)
            go_next_button.setEnabled(self.scrolled_to_selected is not None)
            go_previous_button.setEnabled(self.scrolled_to_selected is not None)

        self.search_start_time = 0
        search_input = QLineEdit()
        search_input.setFixedWidth(350)
        search_input.addAction(get_themed_icon(ICON_SEARCH_TEXT), QLineEdit.LeadingPosition)

        re_action = search_input.addAction(get_themed_icon(ICON_PLAIN_TEXT_SEARCH), QLineEdit.TrailingPosition)
        re_action.setCheckable(True)
        search_tip = tr(
            "Incrementally search journal entries.\nSearches all fields.\n"
            "Click the icon in the right margin\nto toggle regexp/plain-text matching.")

        def re_search_toggle(enable: bool):
            self.re_search_enabled = enable
            manage_icon(re_action, ICON_REGEXP_SEARCH if enable else ICON_PLAIN_TEXT_SEARCH)
            tip = tr("Regular expression matching enabled.") if enable else tr("Plain-text matching enabled.")
            self.journal_status_bar.show_info(tip)
            search_input.setToolTip(search_tip + "\n" + tip)

        re_action.toggled.connect(re_search_toggle)
        search_input.setToolTip(search_tip)
        search_input.textEdited.connect(search_entries)
        search_input.setClearButtonEnabled(True)
        self.search_input = search_input
        title_layout.addWidget(search_input)
        self.scrolled_to_selected = None

        go_next_button = transparent_button(manage_icon(QPushButton('', self), ICON_GO_NEXT))
        go_next_button.clicked.connect(partial(self.scroll_selected, 1))
        go_next_button.setEnabled(False)
        go_next_button.setToolTip(tr("Next match."))
        title_layout.addWidget(go_next_button)

        go_previous_button = transparent_button(manage_icon(QPushButton('', self), ICON_GO_PREVIOUS))
        go_previous_button.clicked.connect(partial(self.scroll_selected, -1))
        go_previous_button.setToolTip(tr("Previous match."))
        go_previous_button.setEnabled(False)
        title_layout.addWidget(go_previous_button)

        self.title_layout = title_layout

        spacer = QWidget()
        spacer.setFixedWidth(10)
        spacer.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
        title_layout.addWidget(spacer)

        layout.addWidget(title_container)

        self.setWindowTitle(tr("Recently notified"))

        layout.addWidget(self.table_view)

        self.journal_status_bar = StatusBar()
        self.static_status_label = QLabel("")
        self.static_pixmap_label = QLabel("")
        self.static_pixmap_label.setPixmap(QIcon.fromTheme('dialog-information').pixmap(64,64))
        self.journal_status_bar.addPermanentWidget(self.static_status_label)
        layout.addWidget(self.journal_status_bar)
        self.static_status_label.setText(tr(""))

        def view_journal_entry_at_row_number(row: int):
            if row < 0 and (self.table_view.model().rowCount() > 0):
                row = self.table_view.model().rowCount() - 1
                self.journal_status_bar.show_info(tr("Viewing last entry."), STATUS_SHORT_TIMEOUT_MSEC)
            if row >= 0:
                journal_entry = self.table_view.model().get_journal_entry(row)
                # entry_dialog = JournalEntryDialogPlain(self, self.table_view.model().get_journal_entry(row), row)
                # entry_dialog.show()
                window_title = tr("Recent Entry #{row} \u2014 {entry}").format(
                    row=row + 1,
                    entry=journal_entry['__REALTIME_TIMESTAMP'])
                text = format_journal_entry(journal_entry)
                status = tr("{kb:.2f} kbytes").format(kb=len(journal_entry[JOUNO_CONSOLIDATED_TEXT_KEY]) / 1024.0)
                ViewTextDialog(title=window_title, text=text, static_status=status)
                self.journal_status_bar.show_info(tr("Viewing entry {}.").format(row + 1), STATUS_SHORT_TIMEOUT_MSEC)
            else:
                self.journal_status_bar.show_warning(tr("No entries available."), STATUS_SHORT_TIMEOUT_MSEC)

        def view_journal_entry_at_index(index: QModelIndex):
            view_journal_entry_at_row_number(index.row())

        def view_journal_entry():
            view_journal_entry_at_row_number(self.context_menu_index.row())

        self.table_view.doubleClicked.connect(view_journal_entry_at_index)
        self.table_view.verticalHeader().sectionDoubleClicked.connect(view_journal_entry_at_row_number)

        def copy_selected():
            selected = self.table_view.selectionModel().selectedRows()
            text = ''
            if len(selected) == 0:
                if self.context_menu_index is not None and self.context_menu_index.row() >= 0:
                    row = self.context_menu_index.row()
                    print('copy a row', row)
                    text = format_journal_entry(self.table_view.model().get_journal_entry(row))
                    self.journal_status_bar.show_info(tr("Copied the entry {} to the clipboard.").format(row + 1),
                                                        STATUS_TIMEOUT_MSEC)
                else:
                    if self.table_view.model().rowCount() > 0:
                        row = self.table_view.model().rowCount() - 1
                        text = format_journal_entry(self.table_view.model().get_journal_entry(row))
                        self.journal_status_bar.show_info(tr("Copied last entry."), STATUS_TIMEOUT_MSEC)
                    else:
                        self.journal_status_bar.show_error(tr("No entries available."), STATUS_TIMEOUT_MSEC)
                        text = ''
            else:
                for index in selected:
                    text += format_journal_entry(self.table_view.model().get_journal_entry(index.row())) + '\n'
                self.journal_status_bar.show_info(
                    tr("Copied {} entries to the clipboard.").format(len(selected)), STATUS_TIMEOUT_MSEC)
            QApplication.clipboard().setText(text)

        context_menu = QMenu(tr("Journal Entry Menu"), parent=self)
        manage_icon(context_menu.addAction(tr('View entry'), view_journal_entry), ICON_VIEW_JOURNAL_ENTRY)
        context_menu.addSeparator()
        manage_icon(context_menu.addAction(tr('Copy selected'), copy_selected), ICON_COPY_SELECTED)
        manage_icon(context_menu.addAction(tr('Clear selection'), self.table_view.clearSelection), ICON_CLEAR_SELECTION)

        self.context_menu_index = None

        def table_context_menu(pos: QPoint):
            self.journal_status_bar.clearMessage()
            self.context_menu_index = self.table_view.indexAt(pos)
            context_menu.exec(self.table_view.mapToGlobal(pos))

        self.table_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.table_view.verticalHeader().setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.table_view.customContextMenuRequested.connect(table_context_menu)
        self.table_view.verticalHeader().customContextMenuRequested.connect(table_context_menu)

    def add_dock_control(self, dock_button: QPushButton):
        self.title_layout.addWidget(dock_button)

    def add_journal_entry(self, journal_entry, notable):
        self.table_view.new_journal_entry(journal_entry, notable)

    def new_journal_entry(self, journal_entry, notable):
        if journal_entry is not None:
            scroll_bar = self.table_view.verticalScrollBar()
            at_bottom = scroll_bar.value() == scroll_bar.maximum()
            self.add_journal_entry(journal_entry, notable)
            message = journal_entry['MESSAGE']
            if self.search_input.text().strip() == '':
                self.journal_status_bar.show_info(
                    tr("New entry. Message={}{}").format(message[0:80], ' ...' if len(message) > 120 else ''),
                    STATUS_TIMEOUT_MSEC)
                if at_bottom:
                    self.table_view.scrollToBottom()
            else:
                self.journal_status_bar.show_warning(
                    tr("New entry (scrolling prevented by search text). Message={}{}").format(
                        journal_entry['MESSAGE'][0:40], ' ...' if len(message) > 80 else ''))
            self.static_status_label.setText(
                tr("{n}/{m}").format(n=self.table_view.model().get_num_entries(),
                                     m=self.table_view.model().get_max_entries()))
        else:
            self.table_view.scrollToBottom()

    def get_selected_journal_entry(self):
        indexes = self.table_view.selectedIndexes()
        if indexes is None or len(indexes) == 0:
            return None
        return self.table_view.model().get_journal_entry(indexes[-1].row())

    def get_last_journal_entry(self):
        if self.table_view.model().rowCount() == 0:
            return None
        return self.table_view.model().get_journal_entry(self.table_view.model().rowCount() - 1)

    def clear_all_entries(self):
        self.table_view.model().remove_all_entries()

    def search_select_journal(self, text: str, regexp_search: bool = False):
        save_triggers = self.table_view.editTriggers()
        try:
            self.table_view.setEditTriggers(QAbstractItemView.NoEditTriggers)
            self.table_view.clearSelection()
            if len(text) == 0:
                self.journal_status_bar.showMessage('')
            else:
                matched_row_count = 0
                model = self.table_view.model()
                last_column = self.table_view.model().columnCount() - 1
                # Assume case-insensitive if all text is in lower case.
                regexp = re.compile(text if regexp_search else re.escape(text),
                                    flags=re.DOTALL | (re.IGNORECASE if text == text.lower() else 0))

                matching_rows_selection = QItemSelection()

                # matched_row_numbers = [row_num for row_num, journal_entry in enumerate(model.journal_entries)
                #                        if regexp.search(journal_entry[JOUNO_CONSOLIDATED_TEXT_KEY]) is not None]
                matched_row_numbers = []
                self.search_start_time = time.time_ns()
                my_start_time = self.search_start_time
                for row_num, journal_entry in enumerate(model.journal_entries):
                    # Keep processing event loop and stop if a newer search has been started.
                    # this makes for a responsive interface.
                    QApplication.processEvents()
                    if self.search_start_time > my_start_time:
                        debug("isearch stopped by typing", text) if debugging else None
                        return
                    # if journal_entry['MESSAGE'].find(' unregistered') > -1 and journal_entry['MESSAGE'].find(
                    #         'Service ') > -1:
                    #     print(journal_entry[JOUNO_CONSOLIDATED_TEXT_KEY])
                    if regexp.search(journal_entry[JOUNO_CONSOLIDATED_TEXT_KEY]) is not None:
                        matched_row_numbers.append(row_num)
                match_count = len(matched_row_numbers)
                if match_count == 0:
                    self.journal_status_bar.show_warning(tr("Nothing matches"))
                elif match_count == len(model.journal_entries):
                    self.journal_status_bar.show_warning(tr("Everything matches."))
                else:
                    for row_n in matched_row_numbers:
                        row_n_selection = QItemSelection(model.index(row_n, 0), model.index(row_n, last_column))
                        matching_rows_selection.merge(row_n_selection, QItemSelectionModel.SelectCurrent)
                    self.scrolled_to_selected = model.index(matching_rows_selection.indexes()[0].row(), 0)
                    self.table_view.scrollTo(self.scrolled_to_selected)
                    self.journal_status_bar.show_info(
                        tr("Matched {match_count} entries.").format(match_count=match_count))
                self.table_view.selectionModel().select(matching_rows_selection, QItemSelectionModel.SelectCurrent)
        finally:
            self.table_view.setEditTriggers(save_triggers)

    def scroll_selected(self, direction: int):
        matched_indexes = self.table_view.selectedIndexes()
        if len(matched_indexes) == 0:
            return
        # Reduce the list of all selected row,col items to a list of one item for each row.
        matched_rows = list({index.row(): index for index in matched_indexes}.values())
        matched_count = len(matched_rows)
        if self.scrolled_to_selected is None or self.scrolled_to_selected not in matched_rows:
            self.scrolled_to_selected = matched_rows[0]
        else:
            new_pos = matched_rows.index(self.scrolled_to_selected) + direction
            if new_pos < 0:
                alert = QMessageBox()
                alert.setText(tr(f'At first match of {matched_count} matches.'))
                alert.setInformativeText(tr('Continue from bottom?'))
                alert.setIcon(QMessageBox.Question)
                alert.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
                ret = alert.exec()
                new_pos = 0 if ret == QMessageBox.No else matched_count - 1
            if new_pos >= matched_count:
                alert = QMessageBox()
                alert.setText(tr(f'At last match of {matched_count} matches.'))
                alert.setInformativeText(tr('Continue from top?'))
                alert.setIcon(QMessageBox.Question)
                alert.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
                ret = alert.exec()
                new_pos = 0 if ret == QMessageBox.Yes else matched_count - 1
            self.scrolled_to_selected = matched_rows[new_pos]
            self.journal_status_bar.show_info(
                tr("Match {}/{}, row {}.").format(new_pos + 1, matched_count, self.scrolled_to_selected.row() + 1))
        self.table_view.scrollTo(self.scrolled_to_selected, QAbstractItemView.PositionAtCenter)

    def set_max_entries(self, max_entries: int) -> None:
        self.table_view.model().set_max_entries(max_entries)


class JournalEntryDelegate(QStyledItemDelegate):

    def createEditor(self, parent, option, index):
        line_edit = QLineEdit(parent)
        # Makes it behave line a normal readonly text entry (unlike making the whole table read only).
        line_edit.setReadOnly(True)
        return line_edit


class JournalTableView(QTableView):

    def __init__(self):
        super().__init__()
        self.setToolTip(tr("Double click to view the row's complete journal entry.\nRight-mouse for other options."))
        self.setModel(JournalTableModel())
        self.setDragDropOverwriteMode(False)
        self.resizeColumnsToContents()
        self.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.setSelectionMode(QAbstractItemView.ExtendedSelection)
        self.setColumnWidth(0, 15 * 14)
        self.setColumnWidth(1, 10 * 14)
        self.setColumnWidth(2, 10 * 14)
        self.setColumnWidth(3, 5 * 14)
        self.setColumnWidth(4, 8 * 14)
        self.horizontalHeader().setSectionResizeMode(4, QHeaderView.Stretch)
        self.horizontalHeader().setDefaultAlignment(Qt.AlignLeft)
        # self.setItemDelegate(JournalEntryDelegate(self.model()))
        # Cannot use xor!
        # self.setEditTriggers(
        #    QAbstractItemView.AnyKeyPressed | QAbstractItemView.SelectedClicked | QAbstractItemView.CurrentChanged)
        self.setEditTriggers(QAbstractItemView.NoEditTriggers)
        self.setShowGrid(False)
        self.setIconSize(QSize(30, 30))

    def new_journal_entry(self, journal_entry, notable):
        self.model().new_journal_entry(journal_entry, notable)


class JournalTableModel(QStandardItemModel):

    def __init__(self):
        super().__init__(0, 5)
        self.max_entries = 100
        self.icon_cache = {}
        self.journal_entries = []
        self.setHorizontalHeaderLabels(
            [tr("Time"), tr("Host"), tr("Source"), tr("PID"), tr("Message"), tr("Size (kB)")])

    def get_journal_entry(self, row: int):
        return self.journal_entries[row]

    def new_journal_entry(self, journal_entry, notable):

        if self.max_entries > 0:
            while self.rowCount() >= self.max_entries:
                self.removeRow(0)
                self.journal_entries.pop(0)

        def align_right(item: QStandardItem):
            item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
            return item

        def selectable(item: QStandardItem):
            item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled | Qt.ItemIsEditable)
            return item

        def set_icon(item: QStandardItem):
            priority = journal_entry['PRIORITY'] if 'PRIORITY' in journal_entry else Priority.NOTICE.value
            if not Priority.EMERGENCY.value <= priority <= Priority.DEBUG.value:
                priority = Priority.NOTICE.value
            notification_icon_name = NOTIFICATION_ICONS[Priority(priority)]
            if True:
                # New behavior - use "disabled" version of normal icon.
                full_icon_name = notification_icon_name if notable else notification_icon_name + "_non_notable"
            else:
                # Old behavior - use trash can icon.
                full_icon_name = notification_icon_name = notification_icon_name if notable else 'edit-delete'
            if full_icon_name in self.icon_cache:
                icon = self.icon_cache[full_icon_name]
            else:
                icon = QIcon.fromTheme(notification_icon_name)
                self.icon_cache[notification_icon_name] = icon
                if full_icon_name != notification_icon_name:
                    icon = create_disabled_icon_from_themed_icon(icon)
                    self.icon_cache[full_icon_name] = icon
            item.setIcon(icon)
            return item

        self.journal_entries.append(journal_entry)

        consolidated_text = journal_entry[JOUNO_CONSOLIDATED_TEXT_KEY]
        source = extract_source_from_considated_text(consolidated_text)
        size_k = f"{len(consolidated_text) / 1024.0:.2f}"

        self.appendRow(
            [
                selectable(align_right(QStandardItem(f"{journal_entry['__REALTIME_TIMESTAMP']:%y-%m-%d %H:%M:%S}"))),
                selectable(QStandardItem(journal_entry['_HOSTNAME'] if '_HOSTNAME' in journal_entry else 'UNKNOWN')),
                selectable(QStandardItem(source)),
                # TODO smarter choice when _PID is not present.
                selectable(align_right(QStandardItem(str(journal_entry['_PID'] if '_PID' in journal_entry else '')))),
                set_icon(selectable(QStandardItem(str(journal_entry['MESSAGE']) if 'MESSAGE' in journal_entry else ''))),
                selectable(align_right(QStandardItem(size_k))),
            ])

    def set_max_entries(self, max_entries: int) -> None:
        self.max_entries = max_entries

    def get_max_entries(self) -> int:
        return self.max_entries

    def get_num_entries(self) -> int:
        return len(self.journal_entries)

    def remove_all_entries(self):
        self.removeRows(0,self.rowCount())
        self.journal_entries = []


def format_journal_entry(journal_entry):
    text = tr("Journal Entry {entry}\n\n").format(entry=journal_entry['__REALTIME_TIMESTAMP'])
    for row, (k, v) in enumerate(sorted(list(journal_entry.items()))):
        if k != JOUNO_CONSOLIDATED_TEXT_KEY:
            text += f"{k:25}: {str(v)}\n"
    return text


class ViewTextDialog(QWidget):

    def __init__(self, title: str, text: str, static_status: str):
        super().__init__(parent=None, flags=Qt.WindowFlags(Qt.Dialog))

        self.setWindowTitle(title)

        title_container = QWidget(self)
        title_layout = QHBoxLayout()
        title_container.setLayout(title_layout)
        title_label = big_label(QLabel(title))
        title_layout.addWidget(title_label)

        spacer = QWidget()
        spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
        title_layout.addWidget(spacer)

        self.re_search_enabled = False

        def search_entries(text_to_find: str) -> None:
            if text_to_find == '':
                status_bar.showMessage('')
            else:
                # Case-insensitive search if text_to_find is all lowercase.
                re_flags = re.DOTALL | (re.IGNORECASE if text_to_find == text_to_find.lower() else 0)
                self.scrolled_to_selected = None
                if self.re_search_enabled:
                    try:
                        matcher = re.compile(text_to_find, flags=re_flags)
                    except re.error as e:
                        status_bar.show_error(str(e))
                        return
                else:
                    matcher = re.compile(re.escape(text_to_find), flags=re_flags)
                matches = matcher.search(text_view.toPlainText())
                if matches is not None:
                    cursor = text_view.textCursor()
                    cursor.setPosition(matches.start())
                    cursor.setPosition(matches.end(), QTextCursor.KeepAnchor);
                    text_view.setTextCursor(cursor)
                    status_bar.show_info(tr("Matched '{}'").format(text_to_find))
                else:
                    status_bar.show_warning(tr("No matches."))

        search_input = QLineEdit()
        search_input.setFixedWidth(350)
        search_input.addAction(get_themed_icon(ICON_SEARCH_TEXT), QLineEdit.LeadingPosition)
        re_action = search_input.addAction(get_themed_icon(ICON_PLAIN_TEXT_SEARCH), QLineEdit.TrailingPosition)
        re_action.setCheckable(True)

        def re_search_toggle(enable: bool):
            self.re_search_enabled = enable
            manage_icon(re_action, ICON_REGEXP_SEARCH if enable else ICON_PLAIN_TEXT_SEARCH)
            status_bar.show_info(
                tr("Regular expression search.") if enable else tr("Plain text search."))

        re_action.toggled.connect(re_search_toggle)
        search_input.setToolTip(tr(
            "Search journal entry.\n"
            "Click the icon in the right margin\nto toggle regexp/plain-text matching."))
        search_input.textEdited.connect(search_entries)
        search_input.setClearButtonEnabled(True)
        title_layout.addWidget(search_input)

        def copy_to_clipboard():
            QGuiApplication.clipboard().setText(text_view.toPlainText())
            status_bar.show_info(tr("Copied all text to the clipboard"), STATUS_TIMEOUT_MSEC)

        copy_button = transparent_button(manage_icon(QPushButton('', self), ICON_COPY_TO_CLIPBOARD))
        copy_button.setToolTip(tr("Copy entire text to clipboard"))
        copy_button.clicked.connect(copy_to_clipboard)
        title_layout.addWidget(QLabel(' '))
        title_layout.addWidget(copy_button)

        layout = QVBoxLayout()
        layout.addWidget(title_container)
        status_bar = StatusBar()

        text_view = QTextEdit()
        text_view.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
        text_view.setReadOnly(True)
        text_view.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
        text_view.setText(text)

        layout.addWidget(text_view)
        layout.addWidget(status_bar)

        self.setLayout(layout)
        self.setMinimumWidth(1200)
        self.setMinimumHeight(950)
        self.adjustSize()

        status_bar.addPermanentWidget(QLabel(static_status))

        # .show() is non-modal, .exec() is modal
        self.show()


class QueryMetaData(QThread):
    finished = pyqtSignal(str)
    progress = pyqtSignal(str)

    def __init__(self):
        super().__init__()
        self.start_date_map: Mapping[DT.date, QueryBootInfo] = {}
        self.end_date_map: Mapping[DT.date, QueryBootInfo] = {}
        self.first_entry_datetime = None
        self.last_entry_datetime = None
        self.boot_sequence_list = []
        self.boot_years = []
        self.unique_field_values = {}
        self.stopped = False

    def run(self) -> None:
        boot_count = 0
        try:
            self.progress.emit(tr("Getting boot data.."))
            # Run for enough time for the external GUI thread to finish its drawing before it is signaled.
            self.msleep(1000)
            with journal.Reader() as reader:
                boot_id_set = reader.query_unique("_BOOT_ID")
            for boot_id in boot_id_set:
                with journal.Reader() as reader:
                    if self.stopped:
                        return
                    reader.this_boot(boot_id)
                    first = reader.get_next()
                    start_datetime = first['__REALTIME_TIMESTAMP']
                    reader.seek_tail()
                    last = reader.get_previous()
                    end_datetime = last['__REALTIME_TIMESTAMP']
                    journal_incomplete = 'MESSAGE' not in last or last['MESSAGE'] != "Journal stopped"
                    info = QueryBootInfo(boot_id, start_datetime, end_datetime, journal_incomplete)
                    start_date = start_datetime.date()
                    end_date = end_datetime.date()
                    if start_date.year not in self.boot_years:
                        self.boot_years.append(start_date.year)
                    if start_date not in self.start_date_map:
                        self.start_date_map[start_date] = []
                    if end_date not in self.end_date_map:
                        self.end_date_map[end_date] = []
                    self.start_date_map[start_datetime.date()].append(info)
                    self.end_date_map[end_datetime.date()].append(info)
                    boot_count += 1
                    if int(time.time() * 1000) % 500 == 0:
                        self.progress.emit(tr("Retrieved {} boot details, continuing..").format(boot_count))

            for sublist in self.start_date_map.values():
                sublist.sort(key=lambda b: b.start_datetime)
                self.boot_sequence_list.extend(sublist)
            self.boot_sequence_list.sort(key=lambda b: b.start_datetime)
            # Incomplete because it's still being written to:
            self.boot_sequence_list[-1].journal_incomplete = False
            self.first_entry_datetime = self.boot_sequence_list[0].start_datetime
            self.last_entry_datetime = self.boot_sequence_list[-1].end_datetime
            self.boot_years.sort()
            for i, boot_info in enumerate(self.boot_sequence_list):
                boot_info.boot_number = i
            config = Config()
            query_fields = config.get('options', 'query_field_list', fallback=' '.join(DEFAULT_QUERY_FIELDS)).split(' ')
            for field_name in query_fields:
                if self.stopped:
                    return
                with journal.Reader() as reader:
                    values_set = reader.query_unique(field_name)
                values_list = [QueryFieldValue(field_name, v) for v in values_set]
                values_list.sort(key=lambda v: v.sort_key)
                self.progress.emit(tr("Retrieved {} unique values for {}").format(len(values_list), field_name))
                self.unique_field_values[field_name] = values_list
        finally:
            self.finished.emit(tr("Retrieved {} boot details.") if not self.stopped else "Abandoned retrieval.")

    def stop(self):
        self.stopped = True


class QueryBootInfo:
    def __init__(self, boot_id, start_datetime: DT.datetime, end_datetime: DT.datetime, journal_incomplete: bool):
        self.boot_id = boot_id
        self.start_datetime = start_datetime
        self.end_datetime = end_datetime
        self.journal_incomplete = journal_incomplete
        self.boot_number = 0


def get_name_from_uid(uid: int) -> str:
    try:
        return pwd.getpwuid(uid).pw_name
    except KeyError:
        return str(uid)


def get_name_from_gid(gid: int) -> str:
    try:
        return grp.getgrgid(gid).gr_name
    except KeyError:
        return str(gid)


def get_uid_from_name(name: str):
    try:
        return pwd.getpwnam(name).pw_uid
    except KeyError:
        try:
            return int(name)
        except ValueError:
            return -1


def get_gid_from_name(name: str):
    try:
        return pwd.getgrnam(name).gr_gid
    except KeyError:
        try:
            return int(name)
        except ValueError:
            return -1


class QueryFieldValue:
    def __init__(self, field_name: str, value):
        self.value = value
        if field_name == '_UID':
            description = get_name_from_uid(value)
            sort_key = description
        elif field_name == '_GID':
            description = get_name_from_gid(value)
            sort_key = description
        else:
            description = str(value)
            sort_key = value
        self.description = description
        self.sort_key = sort_key


class QueryInitializeWidget(QProgressDialog):
    def __init__(self, parent: MainWindow):
        super().__init__(tr("Retrieving Journal Metadata"), tr("Cancel"), 0, 10, parent=parent)
        self.setFixedHeight(200)
        self.setFixedWidth(800)
        layout = QVBoxLayout()
        layout.setAlignment(Qt.AlignHCenter)
        self.setLayout(layout)
        status_label = QLabel(tr("Retrieving journal metadata..."))
        layout.addWidget(status_label)
        self.step = 0

        def progress_func(message: str):
            status_label.setText(message)
            self.step += 1
            self.setValue(self.step)

        def finished_func(message: str):
            if not query_metadata.stopped:
                QueryJournalWidget(query_metadata, parent)
            self.close()

        query_metadata = QueryMetaData()
        query_metadata.progress.connect(progress_func)
        query_metadata.finished.connect(finished_func)
        self.canceled.connect(query_metadata.stop)
        self.show()
        self.raise_()
        self.activateWindow()
        query_metadata.start()


class QueryJournalWidget(QMainWindow):
    def __init__(self, query_meta_data: QueryMetaData, parent: MainWindow):
        super().__init__(parent=parent, flags=Qt.Dialog)
        self.main_window = parent
        self.setObjectName("journal-query")

        self.journal_meta_data = query_meta_data
        self.query_task = None

        central = QWidget()
        layout = QFormLayout()
        central.setLayout(layout)

        title_widget = QWidget()
        self.title_layout = QHBoxLayout()
        title_widget.setLayout(self.title_layout)
        self.title_layout.addWidget((big_label(QLabel(tr("Journal Query")))))
        spacer = QSpacerItem(QSizePolicy.Expanding, QSizePolicy.Preferred)
        # spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
        self.title_layout.addItem(spacer)
        layout.addRow(title_widget)
        self.query_results = []

        self.query_desc_widget = QTextEdit()
        self.query_desc_widget.setMaximumHeight(150)
        self.query_desc_widget.setReadOnly(True)
        layout.addRow(tr("Query:"), self.query_desc_widget)

        def row_limit_func(text: str):
            try:
                self.row_limit = int(text)
                self.query_desc_widget.setText(self.query_description())
            except ValueError:
                pass

        self.row_limit = 0
        self.limit_rows_widget = QLineEdit()
        self.limit_rows_widget.setText("0")
        self.limit_rows_widget.setMaximumWidth(150)
        self.limit_rows_widget.setValidator(QIntValidator())
        self.limit_rows_widget.textChanged.connect(row_limit_func)
        layout.addRow(tr("&Row Limit"), self.limit_rows_widget)

        def picked_from_date_func(picked_datetime: QDateTime):
            self.from_date_time = picked_datetime.toPyDateTime()
            self.query_desc_widget.setText(self.query_description())
            # to_date_widget.setMinimumDate(datetime)

        def picked_to_date_func(picked_datetime: QDateTime):
            self.to_date_time = picked_datetime.toPyDateTime()
            self.query_desc_widget.setText(self.query_description())

        self.from_date_time = self.journal_meta_data.first_entry_datetime
        self.to_date_time = self.journal_meta_data.last_entry_datetime
        from_date_widget = QDateTimeEdit()
        from_date_widget.setDateTime(self.from_date_time)
        from_date_widget.setDisplayFormat("yyyy.MM.dd hh:mm:ss")
        from_date_widget.setCalendarPopup(True)
        from_date_widget.dateTimeChanged.connect(picked_from_date_func)
        to_date_widget = QDateTimeEdit()
        to_date_widget.setDateTime(self.to_date_time)
        to_date_widget.setDisplayFormat("yyyy.MM.dd hh:mm:ss")
        to_date_widget.setCalendarPopup(True)
        to_date_widget.dateTimeChanged.connect(picked_to_date_func)
        layout.addRow(tr("&From"), from_date_widget)
        layout.addRow(tr("&To"), to_date_widget)

        tab_widget = QTabWidget()
        layout.addRow(tr("Criteria"), tab_widget)

        def value_checked_func():
            self.query_desc_widget.setText(self.query_description())

        self.boot_picker = QueryBootWidget(self.journal_meta_data, value_checked_func, self)
        tab_widget.addTab(self.boot_picker, "Boot")

        self.field_query_widget_list = []
        for field_name, field_values in self.journal_meta_data.unique_field_values.items():
            if len(field_values) > 0:
                field_query_widget = QueryFieldWidget(field_name, field_values, value_checked_func, self)
                tab_widget.addTab(field_query_widget, field_name)
                self.field_query_widget_list.append(field_query_widget)

        def validate_filter_func(text):
            if text is None:
                text = results_filter_edit.text()
            if regexp_checkbox.isChecked():
                try:
                    re.compile(text, flags=re.DOTALL)
                    self.run_query_button.setEnabled(True)
                    self.results_filter = text
                    self.results_filter_is_regexp = True
                    self.query_desc_widget.setText(self.query_description())
                except re.error as e:
                    self.status_bar.show_error(str(e))
                    self.run_query_button.setDisabled(True)
            else:
                self.run_query_button.setEnabled(True)
                re.compile(re.escape(text), flags=re.DOTALL)
                self.results_filter = text
                self.results_filter_is_regexp = False
                self.query_desc_widget.setText(self.query_description())

        self.results_filter = ''
        self.results_filter_is_regexp = False
        results_filter_edit = QLineEdit(parent=parent)
        results_filter_edit.setMinimumWidth(800)
        results_filter_edit.textChanged.connect(validate_filter_func)
        regexp_checkbox = QCheckBox(tr("reg-exp"))
        regexp_checkbox.setChecked(self.results_filter_is_regexp)
        regexp_checkbox.clicked.connect(partial(validate_filter_func, None))
        filter_box = QWidget()
        filter_layout = QHBoxLayout()
        filter_box.setLayout(filter_layout)
        filter_layout.addWidget(results_filter_edit)
        filter_layout.addWidget(regexp_checkbox)
        filter_layout.setContentsMargins(0, 0, 0, 0)
        results_filter_edit.setText(self.results_filter)
        layout.addRow(tr("&Results Filter"), filter_box)

        button_box = QWidget()
        button_box_layout = QHBoxLayout()
        button_box.setLayout(button_box_layout)
        self.run_query_button = manage_icon(QPushButton(tr("Run Query")), SVG_TOOLBAR_RUN_ENABLED)
        self.run_query_button.clicked.connect(self.perform_query)
        button_box_layout.addWidget(self.run_query_button)

        def stop_func():
            self.query_task.stop()

        self.stop_button = manage_icon(QPushButton(tr("Stop Query")), SVG_TOOLBAR_STOP)
        self.stop_button.clicked.connect(stop_func)
        self.stop_button.setEnabled(False)
        button_box_layout.addWidget(self.stop_button)

        def reset_func(initialization: bool = False):
            self.from_date_time = self.journal_meta_data.first_entry_datetime
            self.to_date_time = self.journal_meta_data.last_entry_datetime
            from_date_widget.setDateTime(self.from_date_time)
            to_date_widget.setDateTime(self.to_date_time)
            self.boot_picker.reset(initialization=initialization)
            for field_widget in self.field_query_widget_list:
                field_widget.reset()
            self.row_limit = 0
            self.limit_rows_widget.setText('0')
            self.query_desc_widget.setText(self.query_description())

        reset_button = manage_icon(QPushButton(tr("Reset Query")), ICON_REVERT)
        reset_button.clicked.connect(reset_func)
        button_box_layout.addWidget(reset_button)

        button_box_layout.addStretch()
        close_button = manage_icon(QPushButton(tr("Close")), ICON_WINDOW_CLOSE)
        close_button.clicked.connect(self.close)
        button_box_layout.addWidget(close_button)

        layout.addRow('', button_box)
        self.query_desc_widget.setText(self.query_description())
        self.setCentralWidget(central)
        self.status_bar = StatusBar()
        self.setStatusBar(self.status_bar)
        self.show()
        reset_func(initialization=True)

    def query_description(self):
        parts_list = []
        if self.row_limit > 0:
            parts_list.append("RESULT_COUNT <= {}".format(self.row_limit))
        parts_list.append("__REALTIME_TIMESTAMP between [{:%y-%m-%d %H:%M}, {:%y-%m-%d %H:%M}]".format(
            self.from_date_time, self.to_date_time))
        boots = self.boot_picker.get_description()
        if boots != '':
            parts_list.append(self.boot_picker.get_description())
        if self.results_filter.strip() != '':
            parts_list.append(
                "   and result {} '{}'".format(
                    'matches' if self.results_filter_is_regexp else 'contains',
                    self.results_filter))
        for field in self.field_query_widget_list:
            description = field.get_description()
            if description != '':
                parts_list.append(description)
        return '\n    and '.join(parts_list)

    def perform_query(self):
        self.stop_button.setEnabled(True)
        self.run_query_button.setDisabled(True)
        if self.results_filter.strip() != '':
            results_filter_pattern = \
                re.compile(self.results_filter if self.results_filter_is_regexp else re.escape(self.results_filter),
                           flags=re.DOTALL)
        else:
            results_filter_pattern = None
        self.query_task = QueryJournalTask(
            from_datetime=self.from_date_time, to_datetime=self.to_date_time,
            boot_list=self.boot_picker.boot_list.copy(),
            field_values_map={f.field_name: f.get_checked_values() for f in self.field_query_widget_list},
            row_limit=self.row_limit,
            results_filter_pattern=results_filter_pattern)
        self.query_task.finished.connect(self.query_finished)

        def progress_func(count: int):
            self.status_bar.show_progress(tr("Found {} entries so far, continuing..").format(count))

        self.query_task.progress.connect(progress_func)
        self.query_task.start()

    def query_finished(self, number_of_matches: int):
        self.stop_button.setEnabled(False)
        elapsed_time = self.query_task.time_query_end - self.query_task.time_query_start
        self.status_bar.show_info(
            tr("Stopped at {} entries at {:.2f} seconds, creating view.."
               if self.query_task.stopped else
               "Retrieved at {} entries in {:.2f} seconds, creating view..").format(number_of_matches, elapsed_time))
        QApplication.processEvents()
        query_result = QWidget()
        query_layout = QVBoxLayout()
        query_result.setLayout(query_layout)
        journal_panel = JournalPanel(max_entries=0)
        title = tr("Query: {}").format(self.query_description())
        journal_panel.title_label.setText(title)
        query_layout.addWidget(journal_panel)
        for journal_entry in self.query_task.results:
            if int(time.time() * 1000) % 2000:
                journal_panel.journal_status_bar.show_info(tr("Initialising."), 1000)
            journal_panel.add_journal_entry(journal_entry, True)
        journal_panel.journal_status_bar.showMessage('')
        journal_panel.static_status_label.setText(
            tr("Stopped at {} entries at {:.2f} seconds."
               if self.query_task.stopped else
               "Retrieved {} entries in {:.2f} seconds.").format(number_of_matches, elapsed_time))
        result_geometry = self.main_window.geometry()
        result_geometry.translate(50, 50)
        query_result.setGeometry(result_geometry)
        query_result.show()
        self.query_results.append(query_result)
        self.query_task = None
        self.run_query_button.setEnabled(True)

    def app_restore_state(self):
        debug("app_restore_state") if debugging else None
        geometry = self.settings.value(self.geometry_key, None)
        if geometry is not None:
            debug(f"Restore {self.geometry_key} {self.state_key}") if debugging else None
            self.restoreGeometry(geometry)
            window_state = self.settings.value(self.state_key, None)
            self.restoreState(window_state)
        self.search_container.app_restore_state(from_settings=self.settings, show=True)


class QueryJournalTask(QThread):
    finished = pyqtSignal(int)
    progress = pyqtSignal(int)

    def __init__(self,
                 from_datetime: DT.datetime, to_datetime: DT.datetime,
                 boot_list: List[str],
                 field_values_map: Mapping[str, List],
                 row_limit: int,
                 results_filter_pattern: re.Pattern):
        super().__init__()
        self.from_datetime = from_datetime
        self.to_datetime = to_datetime
        self.boot_list = boot_list
        self.field_values_map = field_values_map
        self.row_limit = row_limit
        self.results_filter_pattern = results_filter_pattern
        self.results = []
        self.stopped = False
        self.time_query_start = 0.0
        self.time_query_end = 0.0

    def run(self):
        try:
            self.time_query_start = time.time()
            number_of_matches = 0
            with journal.Reader() as query_reader:
                query_reader.seek_realtime(self.from_datetime)
                for boot_id in self.boot_list:
                    query_reader.this_boot(boot_id)
                for field_name, field_values in self.field_values_map.items():
                    for value in field_values:
                        match_str = "{}={}".format(field_name, value)
                        query_reader.add_match(match_str)
                while True:
                    if self.stopped:
                        break
                    journal_entry = query_reader.get_next()
                    # at end of journal returns {} an empty dictionary
                    if journal_entry is None or len(journal_entry) == 0:
                        break
                    journal_entry_date_time = journal_entry['__REALTIME_TIMESTAMP']
                    if journal_entry_date_time.replace(tzinfo=pytz.UTC) > self.to_datetime.replace(tzinfo=pytz.UTC):
                        break
                    text = consolidate_text(journal_entry)
                    if self.results_filter_pattern is None or self.results_filter_pattern.search(text) is not None:
                        number_of_matches += 1
                        self.results.append(journal_entry)
                    if int(time.time() * 1000) % 1000 == 0:
                        self.progress.emit(number_of_matches)
                    if self.row_limit > 0 and number_of_matches == self.row_limit:
                        break
        finally:
            self.time_query_end = time.time()
            self.finished.emit(number_of_matches)

    def stop(self):
        self.stopped = True


class QueryBootWidget(QWidget):
    def __init__(self, journal_metadata: QueryMetaData, boot_picked_func: Callable, parent: QWidget):
        super().__init__(parent=parent)
        self.boot_list = []
        self.journal_metadata = journal_metadata
        layout = QVBoxLayout()
        self.setLayout(layout)

        def calendar_activated_func(date: QDate):
            boot_table.clearSelection()
            picked_date = date.toPyDate()
            if picked_date in journal_metadata.start_date_map:
                row_num = journal_metadata.start_date_map[picked_date][0].boot_number
                boot_table.scrollToItem(boot_table.item(row_num, 0), QAbstractItemView.PositionAtTop)
                # boot_table.clearSelection()
                # Reverse order so that the first in list remains visible in the view.
                for day_boot in journal_metadata.start_date_map[picked_date][::-1]:
                    item = boot_table.item(day_boot.boot_number, 0)
                    item.setCheckState(Qt.Checked if item.checkState() != Qt.Checked else Qt.Unchecked)

        calendar = QueryBootTimelineWidget(boot_index=journal_metadata, parent=self)
        layout.addWidget(calendar, 0, Qt.AlignTop)
        calendar.activated.connect(calendar_activated_func)
        self.calendar = calendar

        self.row_color_light_theme = QColor(0xfcfcfc), QColor(0xf1f1f1), QColor(0xffdcdc)
        self.row_color_dark_theme = QColor(0x1b1b1b), QColor(0x2e2e2e), QColor(0x550000)
        self.row_color_theme = self.row_color_dark_theme if is_dark_theme() else self.row_color_light_theme
        self.row_bg_color = self.row_color_theme[0]

        boot_table = QTableWidget(len(journal_metadata.boot_sequence_list), 4, self)
        boot_table.setSelectionMode(QTableWidget.SelectionMode.MultiSelection)
        boot_table.setHorizontalHeaderLabels(["Start", "End", "State", "BOOT_ID"])
        boot_table.sizePolicy().setVerticalStretch(10)
        boot_table.setEditTriggers(QAbstractItemView.NoEditTriggers)

        def cell_changed_func(row: int, col: int):
            cell_boot_info = journal_metadata.boot_sequence_list[row]
            boot_id = cell_boot_info.boot_id
            calendar.set_selected_date(cell_boot_info.start_datetime.date(), block_signals=True)
            item = boot_table.item(row, col)
            if item.checkState() == Qt.Checked:
                if boot_id not in self.boot_list:
                    self.boot_list.append(boot_id)
            else:
                if boot_id in self.boot_list:
                    self.boot_list.remove(boot_id)
            boot_picked_func()

        header = boot_table.horizontalHeader()
        header.setSectionResizeMode(QHeaderView.ResizeToContents)
        previous_boot_date = journal_metadata.boot_sequence_list[0].start_datetime.date()

        for i, boot_info in enumerate(journal_metadata.boot_sequence_list):
            row_bg = self.choose_row_color(boot_info, previous_boot_date)
            previous_boot_date = boot_info.start_datetime.date()
            start_datetime_item = QTableWidgetItem(f"{boot_info.start_datetime:%y-%m-%d %H:%M}")
            start_datetime_item.setFlags(
                Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
            start_datetime_item.setCheckState(Qt.Unchecked)
            start_datetime_item.setBackground(row_bg)
            end_datetime_item = QTableWidgetItem(f"{boot_info.end_datetime:%y-%m-%d %H:%M} ")
            end_datetime_item.setBackground(row_bg)
            crashed_item = QTableWidgetItem("incomplete" if boot_info.journal_incomplete else '')
            crashed_item.setBackground(row_bg)
            boot_id_item = QTableWidgetItem(f"{boot_info.boot_id}")
            boot_id_item.setBackground(row_bg)
            boot_table.setItem(i, 0, start_datetime_item)
            boot_table.setItem(i, 1, end_datetime_item)
            boot_table.setItem(i, 2, crashed_item)
            boot_table.setItem(i, 3, boot_id_item)
        layout.addWidget(boot_table, Qt.AlignTop)
        calendar.set_selected_date(DT.date.today(), block_signals=True)
        boot_table.cellChanged.connect(cell_changed_func)

        def as_csv() -> str:
            csv_text = 'Start, End, State, BOOT_ID\n'
            for r in range(0, self.boot_table.rowCount()):
                sep = ''
                for c in range(0, self.boot_table.columnCount()):
                    csv_text += sep + self.boot_table.item(r, c).text()
                    sep = ', '
                csv_text += '\n'
            return csv_text

        def view_text_func():
            csv_text = as_csv()
            ViewTextDialog(tr("Unique values: {}").format('BOOT_ID'), as_csv(),
                           tr("{} lines").format(csv_text.count('\n')), self)

        def copy_func():
            QApplication.clipboard().setText(as_csv())

        def context_menu_func(point: QPoint):
            menu = QMenu(boot_table)
            manage_icon(menu.addAction(tr('View as CSV'), view_text_func), ICON_VIEW_JOURNAL_ENTRY)
            manage_icon(menu.addAction(tr('Copy as CSV'), copy_func), ICON_COPY_SELECTED)
            menu.exec(boot_table.mapToGlobal(point))

        boot_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        boot_table.customContextMenuRequested.connect(context_menu_func)

        self.boot_table = boot_table
        self.reset()

    def choose_row_color(self, boot_info: QueryBootInfo, previous_boot_date: DT.date):
        if boot_info.journal_incomplete:
            return self.row_color_theme[2]
        if boot_info.start_datetime.date() != previous_boot_date:
            self.row_bg_color = self.row_color_theme[1] if self.row_bg_color == self.row_color_theme[0] else \
                self.row_color_theme[0]
        return self.row_bg_color

    def get_description(self):
        values = self.boot_list
        if len(values) == 0:
            return ''
        elif len(values) == 1:
            return "{} = {}".format('_BOOT_ID', str(values[0]))
        else:
            return "{} in [{}]".format('_BOOT_ID', ',\n        '.join(str(value) for value in values))

    def reset(self, initialization: bool = False):
        for i in range(0, self.boot_table.rowCount()):
            self.boot_table.item(i, 0).setCheckState(Qt.Unchecked)
        self.boot_table.scrollToBottom()
        if initialization:
            self.calendar.set_selected_date(DT.date.today(), block_signals=True)
        self.boot_list = []

    def event(self, event: QEvent) -> bool:
        try:
            return super().event(event)
        finally:
            # PalletChange happens after the new style sheet is in use.
            if event.type() == QEvent.PaletteChange:
                self.row_color_theme = self.row_color_dark_theme if is_dark_theme() else self.row_color_light_theme
                previous_boot_date = self.journal_metadata.boot_sequence_list[0].start_datetime.date()
                for i, boot_info in enumerate(self.journal_metadata.boot_sequence_list):
                    for j in range(0, self.boot_table.columnCount()):
                        item = self.boot_table.item(i, j)
                        item.setBackground(self.choose_row_color(boot_info, previous_boot_date))
                        previous_boot_date = boot_info.start_datetime.date()


class QueryBootTimelineWidget(QWidget):
    activated = pyqtSignal(QDate)

    def __init__(self, boot_index: QueryMetaData, parent: QWidget):
        super().__init__(parent=parent)
        start_date = boot_index.first_entry_datetime.date()
        end_date = boot_index.last_entry_datetime.date()
        self.selected_date = DT.date.today()
        layout = QHBoxLayout()
        layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(layout)

        timespan_layout = QHBoxLayout()
        scroll_area = QScrollArea()
        scroll_area.setWidgetResizable(True)
        timespan_widget = QWidget(scroll_area)
        timespan_widget.setLayout(timespan_layout)
        scroll_area.setWidget(timespan_widget)
        scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        layout.addWidget(scroll_area)
        self.scroll_area = scroll_area

        def activated_func(date: QDate):
            self.activated.emit(date)

        self.calendar_list = []
        cal_date = start_date
        while cal_date <= end_date:
            calendar = QueryBootCalendar(boot_index)
            end_of_month = DT.date(cal_date.year + int(cal_date.month / 12), (cal_date.month % 12) + 1,
                                   1) - DT.timedelta(days=1)
            calendar.setDateRange(cal_date, end_of_month)
            calendar.setNavigationBarVisible(False)
            calendar.activated.connect(activated_func)
            cal_box = QWidget()
            cal_box_layout = QVBoxLayout()
            cal_box.setLayout(cal_box_layout)
            cal_title_label = big_label(QLabel(f"{cal_date:%B} {cal_date.year}"))
            cal_box_layout.addWidget(cal_title_label)
            cal_box_layout.addWidget(calendar)
            timespan_layout.addWidget(cal_box)
            self.calendar_list.append(calendar)
            cal_date = end_of_month + DT.timedelta(days=1)
        scroll_area.adjustSize()
        scroll_area.setFixedHeight(scroll_area.height() + 20)

    def get_selected_date(self) -> DT.date:
        return self.selected_date

    def set_selected_date(self, new_date: DT.date, block_signals: bool = False) -> None:
        for calendar in self.calendar_list:
            cal_start_date = calendar.minimumDate().toPyDate()
            if new_date.year == cal_start_date.year and new_date.month == cal_start_date.month:
                if block_signals:
                    calendar.blockSignals(True)
                calendar.set_selected_date(new_date)
                self.scroll_area.ensureWidgetVisible(calendar)
                if block_signals:
                    calendar.blockSignals(False)


class QueryBootCalendar(QCalendarWidget):

    def __init__(self, boot_index: QueryMetaData, parent=None):
        super().__init__(parent)
        self.boot_index = boot_index
        self.small_font = None

    def get_selected_date(self):
        return self.selectedDate().toPyDate()

    def set_selected_date(self, new_date: DT.date):
        self.setSelectedDate(new_date)

    def paintCell(self, painter, rect, date):
        super().paintCell(painter, rect, date)
        if not painter.isActive():
            return
        py_date = date.toPyDate()
        if py_date in self.boot_index.start_date_map:
            boot_count = len(self.boot_index.start_date_map[py_date])
            painter.save()
            if boot_count > 1:
                if self.small_font is None:
                    self.small_font = painter.font()
                    self.small_font.setPointSize(self.small_font.pointSize() - 2)
                painter.setFont(self.small_font)
                painter.drawText(rect.topLeft() + QPoint(16, 12), str(boot_count))
            painter.setBrush(Qt.green)
            painter.setPen(Qt.green)
            painter.drawEllipse(rect.topLeft() + QPoint(12, 8), 3, 3)
            painter.restore()

        if py_date in self.boot_index.end_date_map:
            shutdowns_on_date = self.boot_index.end_date_map[py_date]
            crashed = True in [boot_info.journal_incomplete for boot_info in shutdowns_on_date]
            painter.save()
            painter.setBrush(Qt.red if crashed else Qt.lightGray)
            painter.setPen(Qt.red if crashed else Qt.lightGray)
            painter.drawEllipse(rect.topLeft() + QPoint(12, 24), 3, 3)
            painter.restore()


class QueryFieldWidget(QGroupBox):
    def __init__(self, field_name: str, field_values: List, value_checked_func: Callable, parent: QueryJournalWidget):
        super().__init__('', parent=parent)
        self.field_name = field_name
        self.setAlignment(Qt.AlignLeft)
        layout = QVBoxLayout()
        self.setLayout(layout)

        scroll_area = QScrollArea()
        scroll_area.setWidgetResizable(True)
        checkbox_container = QWidget(scroll_area)
        checkbox_container_layout = QGridLayout()
        checkbox_container.setLayout(checkbox_container_layout)
        scroll_area.setWidget(checkbox_container)
        scroll_area.setContentsMargins(0, 0, 0, 0)
        checkbox_container_layout.setSizeConstraint(QLayout.SizeConstraint.SetMinimumSize)
        layout.addWidget(scroll_area)
        # self.setFlat(True)
        max_str_len = max(len(str(v.description)) for v in field_values)
        num_cols = 5 if max_str_len < 20 else (100 // max_str_len)
        self.checkbox_list = []
        for i, field_value in enumerate(field_values):
            tooltip = "{}={} ({})".format(field_name, field_value.value, field_value.description)
            checkbox = QCheckBox(field_value.description)
            checkbox.setToolTip(tooltip)
            if value_checked_func is not None:
                checkbox.stateChanged.connect(value_checked_func)
            self.checkbox_list.append(checkbox)
            checkbox_container_layout.addWidget(checkbox, i // num_cols, i % num_cols, Qt.AlignLeft)
        # Stop the inter-cell spacing from expanding by consuming it with spacers
        v_spacer = QSpacerItem(QSizePolicy.Expanding, QSizePolicy.Expanding)
        checkbox_container_layout.addItem(v_spacer, checkbox_container_layout.rowCount(), 0, 1, -1)
        h_spacer = QSpacerItem(QSizePolicy.Expanding, QSizePolicy.Minimum)
        checkbox_container_layout.addItem(h_spacer, 0, checkbox_container_layout.columnCount(), -1, 1)

        def as_csv() -> str:
            csv_text = field_name + ', Description\n'
            for fv in field_values:
                csv_text += str(fv.value) + ', ' + fv.description + '\n'
            return csv_text

        def view_text_func():
            csv_text = as_csv()
            ViewTextDialog(tr("Unique values: {}").format(field_name), as_csv(),
                           tr("{} lines").format(csv_text.count('\n')), self)

        def copy_func():
            QApplication.clipboard().setText(as_csv())

        def context_menu_func(point: QPoint):
            menu = QMenu(checkbox_container)
            manage_icon(menu.addAction(tr('View as CSV'), view_text_func), ICON_VIEW_JOURNAL_ENTRY)
            manage_icon(menu.addAction(tr('&Copy as CSV'), copy_func), ICON_COPY_SELECTED)
            menu.exec(checkbox_container.mapToGlobal(point))

        checkbox_container.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        checkbox_container.customContextMenuRequested.connect(context_menu_func)

    def get_checked_values(self):
        if self.field_name == '_UID':
            return [get_uid_from_name(checkbox.text()) for checkbox in self.checkbox_list if checkbox.isChecked()]
        elif self.field_name == '_GID':
            return [get_gid_from_name(checkbox.text()) for checkbox in self.checkbox_list if checkbox.isChecked()]
        return [checkbox.text() for checkbox in self.checkbox_list if checkbox.isChecked()]

    def get_description(self):
        values = self.get_checked_values()
        if len(values) == 0:
            return ''
        elif len(values) == 1:
            return "{} = {}".format(self.field_name, str(values[0]))
        else:
            return "{} in [{}]".format(self.field_name, ', '.join([str(v) for v in values]))

    def reset(self):
        for checkbox in self.checkbox_list:
            checkbox.setChecked(False)
    # def resizeEvent(self, a0: QResizeEvent) -> None:
    #     num_cols = self.width() / 300
    #     for i, box in enumerate(self.boxes):
    #         self.grid_layout.removeWidget(box)
    #         self.grid_layout.addWidget(box, i / num_cols, i % num_cols, Qt.AlignLeft)


class DialogSingletonMixin:
    """
    A mixin that can augment a QDialog or QMessageBox with code to enforce a singleton UI.
    For example, it is used so that only ones settings editor can be active at a time.
    """
    _dialogs_map = {}
    debug = False

    def __init__(self) -> None:
        """Registers the concrete class as a singleton so it can be reused later."""
        super().__init__()
        class_name = self.__class__.__name__
        if class_name in DialogSingletonMixin._dialogs_map:
            raise TypeError(f"ERROR: More than one instance of {class_name} cannot exist.")
        if DialogSingletonMixin.debug:
            debug(f'SingletonDialog created for {class_name}') if debugging else None
        DialogSingletonMixin._dialogs_map[class_name] = self

    def closeEvent(self, event) -> None:
        """Subclasses that implement their own closeEvent must call this closeEvent to deregister the singleton"""
        class_name = self.__class__.__name__
        if DialogSingletonMixin.debug:
            debug(f'SingletonDialog remove {class_name}') if debugging else None
        del DialogSingletonMixin._dialogs_map[class_name]
        event.accept()

    def make_visible(self):
        """
        If the dialog exists(), call this to make it visible by raising it.
        Internal, used by the class method show_existing_dialog()
        """
        self.show()
        self.raise_()
        self.activateWindow()

    @classmethod
    def show_existing_dialog(cls: Type):
        """If the dialog exists(), call this to make it visible by raising it."""
        class_name = cls.__name__
        if DialogSingletonMixin.debug:
            debug(f'SingletonDialog show existing {class_name}') if debugging else None
        instance = DialogSingletonMixin._dialogs_map[class_name]
        instance.make_visible()

    @classmethod
    def exists(cls: Type) -> bool:
        """Returns true if the dialog has already been created."""
        class_name = cls.__name__
        if DialogSingletonMixin.debug:
            debug(
                f'SingletonDialog exists {class_name} {class_name in DialogSingletonMixin._dialogs_map}') if debugging else None
        return class_name in DialogSingletonMixin._dialogs_map


class AboutDialog(QMessageBox, DialogSingletonMixin):

    @staticmethod
    def invoke():
        if AboutDialog.exists():
            AboutDialog.show_existing_dialog()
        else:
            AboutDialog()

    def __init__(self):
        super().__init__()
        self.setWindowTitle(tr('About'))
        self.setTextFormat(Qt.AutoText)
        self.setText(tr('About jouno'))
        self.setInformativeText(tr(ABOUT_TEXT))
        self.setIcon(QMessageBox.Information)
        self.exec()


class HelpDialog(QDialog, DialogSingletonMixin):

    @staticmethod
    def invoke():
        if HelpDialog.exists():
            HelpDialog.show_existing_dialog()
        else:
            HelpDialog()

    def __init__(self):
        super().__init__()
        self.setWindowTitle(tr('Help'))
        layout = QVBoxLayout()
        markdown_view = QTextEdit()
        markdown_view.setReadOnly(True)
        markdown_view.setMarkdown(__doc__)
        layout.addWidget(markdown_view)
        self.setLayout(layout)
        # TODO maybe compute a minimum from the actual screen size or use geometry
        self.setMinimumWidth(1400)
        self.setMinimumHeight(1000)
        # .show() is non-modal, .exec() is modal
        self.make_visible()


class ContextMenu(QMenu):

    def __init__(self, about_action=None, help_action=None, listen_action=None, quit_action=None) -> None:
        super().__init__()

        self.play_pause_action = self.addAction(
            ICON_CONTEXT_MENU_LISTENING_DISABLE,
            tr('Pause'),
            listen_action)
        manage_icon(self.addAction(tr('About'), about_action), ICON_HELP_ABOUT)
        manage_icon(self.addAction(tr('Help'), help_action), ICON_HELP_CONTENTS)
        self.addSeparator()
        manage_icon(self.addAction(tr('Quit'), quit_action), ICON_APPLICATION_EXIT)


def exception_handler(e_type, e_value, e_traceback):
    """Overarching error handler in case something unexpected happens."""
    error("\n", ''.join(traceback.format_exception(e_type, e_value, e_traceback)))
    alert = QMessageBox()
    alert.setText(tr('Error: {}').format(''.join(traceback.format_exception_only(e_type, e_value))))
    alert.setInformativeText(tr('Unexpected error'))
    alert.setDetailedText(
        tr('Details: {}').format(''.join(traceback.format_exception(e_type, e_value, e_traceback))))
    alert.setIcon(QMessageBox.Critical)
    alert.exec()
    QApplication.quit()


def install_as_desktop_application(uninstall: bool = False):
    """Self install this script in the current Linux user's bin directory and desktop applications->settings menu."""
    desktop_dir = Path.home().joinpath('.local', 'share', 'applications')
    icon_dir = Path.home().joinpath('.local', 'share', 'icons')

    if not desktop_dir.exists():
        warning("creating:{desktop_dir.as_posix()}")
        os.mkdir(desktop_dir)

    bin_dir = Path.home().joinpath('bin')
    if not bin_dir.is_dir():
        warning("creating:{bin_dir.as_posix()}")
        os.mkdir(bin_dir)

    if not icon_dir.is_dir():
        warning("creating:{icon_dir.as_posix()}")
        os.mkdir(icon_dir)

    installed_script_path = bin_dir.joinpath("jouno")
    desktop_definition_path = desktop_dir.joinpath("jouno.desktop")
    icon_path = icon_dir.joinpath("jouno.png")

    if uninstall:
        os.remove(installed_script_path)
        info(f'removed {installed_script_path.as_posix()}')
        os.remove(desktop_definition_path)
        info(f'removed {desktop_definition_path.as_posix()}')
        os.remove(icon_path)
        info(f'removed {icon_path.as_posix()}')
        return

    if installed_script_path.exists():
        warning(f"skipping installation of {installed_script_path.as_posix()}, it is already present.")
    else:
        source = open(__file__).read()
        source = source.replace("#!/usr/bin/python3", '#!' + sys.executable)
        info(f'creating {installed_script_path.as_posix()}')
        open(installed_script_path, 'w').write(source)
        info(f'chmod u+rwx {installed_script_path.as_posix()}')
        os.chmod(installed_script_path, stat.S_IRWXU)

    if desktop_definition_path.exists():
        warning(f"skipping installation of {desktop_definition_path.as_posix()}, it is already present.")
    else:
        info(f'creating {desktop_definition_path.as_posix()}')
        desktop_definition = textwrap.dedent(f"""
            [Desktop Entry]
            Type=Application
            Exec={installed_script_path.as_posix()}
            Name=jouno
            GenericName=juno
            Comment=A Systemd-Journal to Freedesktop-Notifications forwarder.
            Icon={icon_path.as_posix()}
            Categories=Qt;System;Monitor;System;
            """)
        open(desktop_definition_path, 'w').write(desktop_definition)

    if icon_path.exists():
        warning(f"skipping installation of {icon_path.as_posix()}, it is already present.")
    else:
        info(f'creating {icon_path.as_posix()}')
        create_pixmap_from_svg_bytes(SVG_JOUNO).save(icon_path.as_posix())

    info('installation complete. Your desktop->applications->system should now contain jouno')


def parse_args():
    args = sys.argv[1:]
    parser = argparse.ArgumentParser(
        description="A Systemd-Journal to Freedesktop-Notifications forwarder",
        formatter_class=argparse.RawTextHelpFormatter)
    parser.epilog = textwrap.dedent(f"""
            """)
    parser.add_argument('--detailed-help', default=False, action='store_true',
                        help='Detailed help (in markdown format).')
    parser.add_argument('--debug', default=False, action='store_true', help='enable debug output to stdout')
    parser.add_argument('--install', action='store_true',
                        help="installs the jouno application in the current user's path and desktop application menu.")
    parser.add_argument('--uninstall', action='store_true',
                        help='uninstalls the jouno application menu file and script for the current user.')
    parsed_args = parser.parse_args(args=args)
    if parsed_args.install:
        install_as_desktop_application()
        sys.exit()
    if parsed_args.uninstall:
        install_as_desktop_application(uninstall=True)
        sys.exit()
    if parsed_args.detailed_help:
        print(__doc__)
        sys.exit()


def main():
    signal.signal(signal.SIGINT, signal.SIG_DFL)
    sys.excepthook = exception_handler
    # Call QApplication before parsing arguments, it will parse and remove Qt session restoration arguments.
    app = QApplication(sys.argv)
    parse_args()
    MainWindow(app)


if __name__ == '__main__':
    main()
