# Copyright 2004-2009 Joe Wreschnig, Michael Urman, Iñigo Serna,
#                     Steven Robertson
#           2011-2022 Nick Boultbee
#           2013      Christoph Reiter
#           2014      Jan Path
#
# 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; either version 2 of the License, or
# (at your option) any later version.

from gi.repository import Gtk, Gio

from quodlibet import config
from quodlibet import qltk
from quodlibet import util
from quodlibet import app
from quodlibet import C_, _
from quodlibet.config import RATINGS, DurationFormat, DURATION

from quodlibet.qltk.ccb import ConfigSwitch as CS
from quodlibet.qltk.data_editors import TagListEditor
from quodlibet.qltk.entry import ValidatingEntry, UndoEntry, ClearEntry
from quodlibet.query._query import Query
from quodlibet.qltk.scanbox import ScanBox
from quodlibet.qltk.maskedbox import MaskedBox
from quodlibet.qltk.songlist import SongList, get_columns
from quodlibet.qltk.window import UniqueWindow
from quodlibet.qltk.x import Button, Align
from quodlibet.qltk import Icons, add_css
from quodlibet.util import copool, format_time_preferred
from quodlibet.util.dprint import print_d
from quodlibet.util.library import emit_signal, get_scan_dirs, scan_library
from quodlibet.util import connect_obj

MARGIN = 12

TOP_MARGIN = 3


class PreferencesWindow(UniqueWindow):
    """The tabbed container window for the main preferences GUI.
    Individual tabs are encapsulated as inner classes inheriting from `VBox`"""

    class SongList(Gtk.VBox):
        name = "songlist"

        PREDEFINED_TAGS = [
            ("~#disc", _("_Disc")),
            ("~#track", _("_Track")),
            ("grouping", _("Grou_ping")),

            ("artist", _("_Artist")),
            ("album", _("Al_bum")),
            ("title", util.tag("title")),

            ("genre", _("_Genre")),
            ("date", _("_Date")),
            ("~basename", _("_Filename")),

            ("~#length", _("_Length")),
            ("~rating", _("_Rating")),
            ("~#filesize", util.tag("~#filesize"))]

        def __init__(self):
            def create_behavior_frame():
                vbox = Gtk.VBox(spacing=12)
                jump_button = CS(_("_Jump to playing song automatically"),
                                 'settings', 'jump', populate=True,
                                 tooltip=_("When the playing song changes, "
                                           "scroll to it in the song list"))
                autosort_button = CS(_("_Sort songs when tags are modified"),
                                     'song_list', 'auto_sort', populate=True,
                                     tooltip=_("Automatically re-sort songs in "
                                               "the song list when tags are modified"))
                always_sortable = CS(_("Always allow sorting"),
                                     'song_list', 'always_allow_sorting', populate=True,
                                     tooltip=_("Allow sorting by column headers, "
                                               "even for playlists etc"))

                def refresh_browser(*args):
                    app.window.set_sortability()
                always_sortable.connect("notify::active", refresh_browser)
                vbox.pack_start(jump_button, False, True, 0)
                vbox.pack_start(always_sortable, False, True, 0)
                vbox.pack_start(autosort_button, False, True, 0)
                return qltk.Frame(_("Behavior"), child=vbox)

            def create_visible_columns_widgets():
                buttons = {}
                vbox = Gtk.VBox(spacing=12)
                grid = Gtk.FlowBox(column_spacing=24)
                for i, (k, t) in enumerate(self.PREDEFINED_TAGS):
                    buttons[k] = Gtk.CheckButton(label=t, use_underline=True)
                    grid.add(buttons[k])
                vbox.pack_start(grid, False, True, 0)
                # Other columns
                hbox = Gtk.HBox(spacing=12)
                l = Gtk.Label(label=_("_Others:"), use_underline=True)
                hbox.pack_start(l, False, True, 0)
                self.others = others = UndoEntry()
                others.set_sensitive(False)
                # Stock edit doesn't have ellipsis chars.
                edit_button = Button(_("_Edit…"), Icons.EDIT)
                edit_button.connect("clicked", self.__config_cols, buttons)
                edit_button.set_tooltip_text(
                    _("Add or remove additional column headers"))
                l.set_mnemonic_widget(edit_button)
                l.set_use_underline(True)
                hbox.pack_start(others, True, True, 0)
                vbox.pack_start(hbox, False, True, 0)
                b = Gtk.HButtonBox()
                b.set_layout(Gtk.ButtonBoxStyle.END)
                b.pack_start(edit_button, True, True, 0)
                vbox.pack_start(b, True, True, 0)
                return qltk.Frame(_("Visible Columns"), child=vbox), buttons

            def create_columns_prefs_frame():
                tiv = Gtk.Switch()
                aio = Gtk.Switch()
                aip = Gtk.Switch()
                fip = Gtk.Switch()
                self._toggle_data = [
                    (tiv, "title", "~title~version"),
                    (aip, "album", "~album~discsubtitle"),
                    (fip, "~basename", "~filename"),
                    (aio, "artist", "~people")
                ]

                def pack_with_label(vb: Gtk.Box, widget: Gtk.Widget, text: str):
                    hb = Gtk.Box(spacing=12)
                    label = Gtk.Label(label=text, use_underline=True)
                    hb.pack_start(label, False, False, 0)
                    hb.pack_end(widget, False, False, 0)
                    vb.pack_start(hb, False, False, 0)

                vb = Gtk.VBox(spacing=12)
                pack_with_label(vb, tiv, _("Title includes _version"))
                pack_with_label(vb, aip, _("Album includes _disc subtitle"))
                pack_with_label(vb, aio, _("Artist includes all _people"))
                pack_with_label(vb, fip, _("Filename includes _folder"))
                return qltk.Frame(_("Column Preferences"), child=vb)

            def create_update_columns_button():
                apply = Button(_("_Update Columns"), Icons.VIEW_REFRESH)
                apply.set_tooltip_text(
                    _("Apply current configuration to song list, "
                      "adding new columns to the end"))
                apply.connect('clicked', self.__apply, buttons)
                # Apply on destroy, else config gets mangled
                self.connect('destroy', self.__apply, buttons)
                b = Gtk.HButtonBox()
                b.set_layout(Gtk.ButtonBoxStyle.END)
                b.pack_start(apply, True, True, 0)
                return b

            super().__init__(spacing=12)
            # Store ordered columns
            self._columns = []
            self.set_border_width(12)
            self.title = _("Song List")
            visible_columns_frame, buttons = create_visible_columns_widgets()
            self.pack_start(create_behavior_frame(), False, True, TOP_MARGIN)
            self.pack_start(visible_columns_frame, False, True, MARGIN)
            self.pack_start(create_columns_prefs_frame(), False, True, MARGIN)
            self.pack_start(create_update_columns_button(), False, False, 0)

            # Run it now
            self.__update(buttons, self._toggle_data, get_columns())

            for child in self.get_children():
                child.show_all()

        def __update(self, buttons, toggle_data, columns):
            """Updates all widgets based on the passed column list"""
            self._columns = columns
            columns = list(columns)

            for key, widget in buttons.items():
                widget.set_active(key in columns)
                if key in columns:
                    columns.remove(key)

            for (check, off, on) in toggle_data:
                if on in columns:
                    buttons[off].set_active(True)
                    check.set_active(True)
                    columns.remove(on)

            self.others.set_text(", ".join(columns))
            self.other_cols = columns

        def __get_current_columns(self, buttons):
            """Given the current column list and the widgets states compute
            a new column list.
            """
            new_headers = set()
            # Get the checked headers
            for key, name in self.PREDEFINED_TAGS:
                if buttons[key].get_active():
                    new_headers.add(key)
                # And the customs
            new_headers.update(set(self.other_cols))

            on_to_off = dict((on, off) for (w, off, on) in self._toggle_data)
            result = []
            cur_cols = get_columns()
            for h in cur_cols:
                if h in new_headers:
                    result.append(h)
                else:
                    try:
                        alternative = on_to_off[h]
                        if alternative in new_headers:
                            result.append(alternative)
                    except KeyError:
                        pass

            # Add new ones, trying to preserve order
            for new in new_headers - set(result):
                try:
                    idx = self._columns.index(new)
                except ValueError:
                    idx = len(self._columns)
                result.insert(idx, new)

            # After this, do the substitutions
            for (check, off, on) in self._toggle_data:
                if check.get_active():
                    try:
                        result[result.index(off)] = on
                    except ValueError:
                        pass

            return result

        def __apply(self, button, buttons):
            result = self.__get_current_columns(buttons)
            SongList.set_all_column_headers(result)

        def __config_cols(self, button, buttons):
            def __closed(widget):
                self.__update(buttons, self._toggle_data, widget.tags)

            columns = self.__get_current_columns(buttons)
            m = TagListEditor(_("Edit Columns"), columns)
            m.set_transient_for(qltk.get_top_parent(self))
            m.connect('destroy', __closed)
            m.show()

    class Browsers(Gtk.VBox):
        name = "browser"

        def __init__(self):
            def create_display_frame():
                vbox = Gtk.VBox(spacing=MARGIN)
                model = Gtk.ListStore(str, str)

                def on_changed(combo):
                    it = combo.get_active_iter()
                    if it is None:
                        return
                    DURATION.format = model[it][0]
                    app.window.songlist.info.refresh()
                    app.window.qexpander.refresh()
                    # TODO: refresh info windows ideally too (but see #2019)

                def draw_duration(column, cell, model, it, data):
                    df, example = model[it]
                    cell.set_property('text', example)

                for df in sorted(DurationFormat.values):
                    # 4954s == longest ever CD, FWIW
                    model.append([df, format_time_preferred(4954, df)])
                duration = Gtk.ComboBox(model=model)
                cell = Gtk.CellRendererText()
                duration.pack_start(cell, True)
                duration.set_cell_data_func(cell, draw_duration, None)
                index = sorted(DurationFormat.values).index(DURATION.format)
                duration.set_active(index)
                duration.connect('changed', on_changed)
                hbox = Gtk.HBox(spacing=MARGIN)
                label = Gtk.Label(label=_("Duration totals") + ":",
                                  use_underline=True)
                label.set_mnemonic_widget(duration)
                hbox.pack_start(label, False, True, 0)
                hbox.pack_start(duration, False, True, 0)

                vbox.pack_start(hbox, False, True, 0)
                return qltk.Frame(_("Display"), child=vbox)

            def create_search_frame():
                vb = Gtk.VBox(spacing=MARGIN)
                l = Gtk.Label(label=_("_Global filter:"))
                l.set_use_underline(True)
                e = ValidatingEntry(Query.validator)
                e.set_text(config.get("browsers", "background"))
                e.connect('changed', self._entry, 'background', 'browsers')
                e.set_tooltip_text(_("Apply this query in addition to all others"))
                l.set_mnemonic_widget(e)
                vb.pack_start(hbox_for(l, e), False, True, 0)
                # Translators: The heading of the preference group, no action
                return qltk.Frame(C_("heading", "Search"), child=vb)

            super().__init__(spacing=MARGIN)
            self.set_border_width(MARGIN)
            self.title = _("Browsers")
            self.pack_start(create_search_frame(), False, True, TOP_MARGIN)
            self.pack_start(create_display_frame(), False, True, MARGIN)

            # Ratings
            c1 = CS(_("Confirm _multiple ratings"),
                    'browsers', 'rating_confirm_multiple', populate=True,
                    tooltip=_("Ask for confirmation before changing the "
                              "rating of multiple songs at once"))

            c2 = CS(_("Enable _one-click ratings"),
                    'browsers', 'rating_click', populate=True,
                    tooltip=_("Enable rating by clicking on the rating "
                              "column in the song list"))

            vbox = Gtk.VBox(spacing=MARGIN)
            vbox.pack_start(c1, False, True, 0)
            vbox.pack_start(c2, False, True, 0)
            f = qltk.Frame(_("Ratings"), child=vbox)
            self.pack_start(f, False, True, MARGIN)

            vb = Gtk.VBox(spacing=MARGIN)

            # Filename choice algorithm config
            sw = CS(_("Prefer _embedded art"),
                    'albumart', 'prefer_embedded', populate=True,
                    tooltip=_("Choose to use artwork embedded in the audio "
                              "(where available) over other sources"))
            vb.pack_start(sw, False, True, 0)

            preferred_image_filename_tooltip = _(
                "The album art image file(s) to use when available "
                "(supports wildcards). If you want to supply more "
                "than one, separate them with commas.")

            sw = CS(_("_Preferred fixed image filename(s)"),
                    'albumart', 'force_filename', populate=True,
                    tooltip=preferred_image_filename_tooltip)
            vb.pack_start(sw, False, True, 0)

            entry = UndoEntry()
            entry.set_tooltip_text(preferred_image_filename_tooltip)
            entry.set_text(config.get("albumart", "filename"))
            entry.connect('changed', self.__changed_text, 'filename')
            # Disable entry when not forcing
            entry.set_sensitive(sw.get_active())
            sw.connect('notify::active', self.__activated_force_filename, entry)
            self.__activated_force_filename(sw, None, entry)
            hb = Gtk.Box()
            entry.set_size_request(250, -1)
            hb.pack_start(entry, False, True, 12)
            vb.pack_start(hb, False, False, 0)

            f = qltk.Frame(_("Album Art"), child=vb)
            self.pack_start(f, False, True, MARGIN)

            for child in self.get_children():
                child.show_all()

        def __changed_text(self, entry, name):
            config.set('albumart', name, entry.get_text())

        def __activated_force_filename(self, switch, state, fn_entry):
            fn_entry.set_sensitive(switch.get_active())

        def _entry(self, entry, name, section="settings"):
            config.set(section, name, entry.get_text())

    class Player(Gtk.VBox):
        name = "playback"

        def _gain_scale_for(self, adj: Gtk.Adjustment) -> Gtk.Scale:
            def format_gain(scale, value):
                return f"{value:.0f} dB"

            scale = Gtk.Scale.new(Gtk.Orientation.HORIZONTAL, adj)
            scale.set_hexpand(True)
            scale.set_show_fill_level(True)
            scale.set_property("round-digits", 0)
            scale.set_value_pos(Gtk.PositionType.LEFT)
            scale.connect('format-value', format_gain)
            return scale

        def __init__(self):
            super().__init__(spacing=MARGIN)
            self.set_border_width(12)
            self.title = _("Playback")

            self.pack_start(self.create_behavior_frame(), False, True, TOP_MARGIN)

            # player backend
            if app.player and hasattr(app.player, 'PlayerPreferences'):
                player_prefs = app.player.PlayerPreferences()
                f = qltk.Frame(_("Output Configuration"), child=player_prefs)
                self.pack_start(f, False, True, MARGIN)

            fallback_gain = config.getfloat("player", "fallback_gain", 0.0)
            adj = Gtk.Adjustment.new(fallback_gain, -12.0, 6.0, 0.5, 1, 0.0)
            adj.connect('value-changed', self.__changed, 'player', 'fallback_gain')
            fb_scale = self._gain_scale_for(adj)
            fb_scale.set_tooltip_text(_("If no Replay Gain information is available "
                                        "for a song, scale the volume by this value"))

            fb_label = Gtk.Label(label=_("_Fall-back gain:"))
            fb_label.set_use_underline(True)
            fb_label.set_alignment(0, 0.5)
            fb_label.set_mnemonic_widget(fb_scale)

            pre_amp_gain = config.getfloat("player", "pre_amp_gain", 0.0)
            adj = Gtk.Adjustment.new(pre_amp_gain, -12, 12, 0.5, 1, 0)
            adj.connect('value-changed', self.__changed, 'player', 'pre_amp_gain')
            pre_scale = self._gain_scale_for(adj)
            pre_scale.set_tooltip_text(_("Scale volume for all songs by this value, "
                                         "as long as the result will not clip"))

            pre_label = Gtk.Label(label=_("_Pre-amp gain:"))
            pre_label.set_use_underline(True)
            pre_label.set_alignment(0, 0.5)
            pre_label.set_mnemonic_widget(pre_scale)

            widgets = [pre_label, pre_scale, fb_label, fb_scale]
            enable_rg = CS(_("_Enable Replay Gain volume adjustment"),
                           "player", "replaygain", populate=True)
            enable_rg.connect('notify::active', self.__activated_gain, widgets)
            self.__activated_gain(enable_rg, None, widgets)

            # packing
            vb = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
            vb.pack_start(enable_rg, False, False, 0)

            grid = Gtk.Grid(column_spacing=6, row_spacing=3)
            grid.attach(fb_label, 0, 0, 1, 1)
            grid.attach(fb_scale, 1, 0, 1, 1)
            grid.attach(pre_label, 0, 1, 1, 1)
            grid.attach(pre_scale, 1, 1, 1, 1)
            hb = Gtk.Box()
            hb.pack_start(grid, True, True, 12)
            vb.pack_start(hb, False, True, 0)
            f = qltk.Frame(_("Replay Gain Volume Adjustment"), child=vb)
            self.pack_start(f, False, True, MARGIN)

            for child in self.get_children():
                child.show_all()

        def create_behavior_frame(self):
            vbox = Gtk.VBox()
            continue_play = CS(_("_Continue playback on startup"),
                               "player", "restore_playing", populate=True,
                               tooltip=_(
                                   "If music is playing on shutdown, automatically "
                                   "start playing on next startup"))
            vbox.pack_start(continue_play, False, False, 0)
            return qltk.Frame(_("Behavior"), child=vbox)

        def __activated_gain(self, activator, state, widgets):
            if app.player:
                # tests
                app.player.reset_replaygain()
            for widget in widgets:
                widget.set_sensitive(activator.get_active())

        def __changed(self, adj, section, name):
            config.set(section, name, str(adj.get_value()))
            app.player.reset_replaygain()

    class Tagging(Gtk.VBox):
        name = "tagging"

        def ratings_vbox(self):
            """Returns a new VBox containing all ratings widgets"""
            vb = Gtk.VBox(spacing=12)

            # Default Rating
            model = Gtk.ListStore(float)
            default_combo = Gtk.ComboBox(model=model)
            default_lab = Gtk.Label(label=_("_Default rating:"))
            default_lab.set_use_underline(True)
            default_lab.set_alignment(0, 0.5)

            def draw_rating(column, cell, model, it, data):
                num = model[it][0]
                text = "%0.2f: %s" % (num, util.format_rating(num))
                cell.set_property('text', text)

            def default_rating_changed(combo, model):
                it = combo.get_active_iter()
                if it is None:
                    return
                RATINGS.default = model[it][0]
                qltk.redraw_all_toplevels()

            def populate_default_rating_model(combo, num):
                model = combo.get_model()
                model.clear()
                deltas = []
                default = RATINGS.default
                precision = RATINGS.precision
                for i in range(0, num + 1):
                    r = i * precision
                    model.append(row=[r])
                    deltas.append((abs(default - r), i))
                active = sorted(deltas)[0][1]
                print_d("Choosing #%d (%.2f), closest to current %.2f"
                        % (active, precision * active, default))
                combo.set_active(active)

            cell = Gtk.CellRendererText()
            default_combo.pack_start(cell, True)
            default_combo.set_cell_data_func(cell, draw_rating, None)
            default_combo.connect('changed', default_rating_changed, model)
            default_lab.set_mnemonic_widget(default_combo)

            def refresh_default_combo(num):
                populate_default_rating_model(default_combo, num)

            # Rating Scale
            model = Gtk.ListStore(int)
            scale_combo = Gtk.ComboBox(model=model)
            scale_lab = Gtk.Label(label=_("Rating _scale:"))
            scale_lab.set_use_underline(True)
            scale_lab.set_mnemonic_widget(scale_combo)

            cell = Gtk.CellRendererText()
            scale_combo.pack_start(cell, False)
            num = RATINGS.number
            for i in [1, 2, 3, 4, 5, 6, 8, 10]:
                it = model.append(row=[i])
                if i == num:
                    scale_combo.set_active_iter(it)

            def draw_rating_scale(column, cell, model, it, data):
                num_stars = model[it][0]
                text = "%d: %s" % (num_stars, RATINGS.full_symbol * num_stars)
                cell.set_property('text', text)

            def rating_scale_changed(combo, model):
                it = combo.get_active_iter()
                if it is None:
                    return
                RATINGS.number = num = model[it][0]
                refresh_default_combo(num)

            refresh_default_combo(RATINGS.number)
            scale_combo.set_cell_data_func(cell, draw_rating_scale, None)
            scale_combo.connect('changed', rating_scale_changed, model)

            default_align = Align(halign=Gtk.Align.START)
            default_align.add(default_lab)
            scale_align = Align(halign=Gtk.Align.START)
            scale_align.add(scale_lab)

            grid = create_grid()
            grid.add(scale_align)
            grid.add(scale_combo)
            grid.attach(default_align, 0, 1, 1, 1)
            grid.attach(default_combo, 1, 1, 1, 1)
            vb.pack_start(grid, False, False, 12)

            # Bayesian Factor
            bayesian_factor = config.getfloat("settings",
                                              "bayesian_rating_factor", 0.0)
            adj = Gtk.Adjustment.new(bayesian_factor, 0.0, 10.0, 0.5, 0.5, 0.0)
            bayes_spin = Gtk.SpinButton(adjustment=adj, numeric=True)
            bayes_spin.set_digits(1)
            bayes_spin.connect('changed', self.__changed_and_signal_library,
                               'settings', 'bayesian_rating_factor')
            bayes_spin.set_tooltip_text(
                _("Bayesian Average factor (C) for aggregated ratings.\n"
                  "0 means a conventional average, higher values mean that "
                  "albums with few tracks will have less extreme ratings. "
                  "Changing this value triggers a re-calculation for all "
                  "albums."))
            bayes_label = Gtk.Label(label=_("_Bayesian averaging amount:"))
            bayes_label.set_use_underline(True)
            bayes_label.set_mnemonic_widget(bayes_spin)

            # Save Ratings
            vb.pack_start(hbox_for(bayes_label, bayes_spin, False), True, True, 0)
            sw = CS(_("Save ratings and play _counts in tags"),
                    "editing", "save_to_songs", populate=True)

            def update_entry(widget, state, email_entry):
                email_entry.set_sensitive(widget.get_active())

            vb.pack_start(sw, True, True, 0)
            lab = Gtk.Label(label=_("_Email:"))
            entry = UndoEntry()
            entry.set_tooltip_text(_("Ratings and play counts will be saved "
                                     "in tags for this email address"))
            entry.set_text(config.get("editing", "save_email"))
            entry.connect('changed', self.__changed, 'editing', 'save_email')

            # Disable the entry if not saving to tags
            sw.connect('notify::active', update_entry, entry)
            update_entry(sw, None, entry)

            lab.set_mnemonic_widget(entry)
            lab.set_use_underline(True)
            vb.pack_start(hbox_for(lab, entry), True, True, 0)

            return vb

        def tag_editing_vbox(self):
            """Returns a new VBox containing all tag editing widgets"""
            vbox = Gtk.VBox(spacing=12)
            sw = CS(_("_Auto-save tag changes"), 'editing',
                    'auto_save_changes', populate=True,
                    tooltip=_("Save changes to tags without confirmation "
                              "when editing multiple files"))
            vbox.pack_start(sw, False, False, 0)

            def revert_split(entry, button, _, section, option):
                config.reset(section, option)
                entry.set_text(config.get(section, option))

            split_entry = ClearEntry()
            gicon = Gio.ThemedIcon.new_from_names(["edit-clear-symbolic", "edit-clear"])
            split_entry.set_icon_from_gicon(Gtk.EntryIconPosition.SECONDARY, gicon)
            split_entry.connect("icon-release", revert_split, "editing", "split_on")
            split_entry.set_text(config.get("editing", "split_on"))
            split_entry.connect('changed', self.__changed, 'editing', 'split_on')
            split_entry.set_tooltip_text(
                _("A set of separators to use when splitting tag values "
                  "in the tag editor. "
                  "The list is space-separated."))
            split_label = Gtk.Label(label=_("Split _tag on:"))
            split_label.set_use_underline(True)
            split_label.set_mnemonic_widget(split_entry)

            vbox.pack_start(hbox_for(split_label, split_entry), False, False, 0)

            sub_entry = ClearEntry()
            sub_entry.enable_clear_button()
            sub_entry.set_text(config.get("editing", "sub_split_on"))
            sub_entry.connect('changed', self.__changed, 'editing', 'sub_split_on')
            sub_entry.connect("icon-release", revert_split, "editing", "sub_split_on")

            sub_entry.set_tooltip_text(
                _("A set of separators to use when extracting subtags from "
                  "tags in the tag editor. "
                  "The list is space-separated, and each entry must only "
                  "contain two characters."))

            sub_label = Gtk.Label(label=_("Split _subtag on:"))
            sub_label.set_use_underline(True)
            sub_label.set_mnemonic_widget(split_entry)

            vbox.pack_start(hbox_for(sub_label, sub_entry), False, False, 0)
            return vbox

        def __init__(self):
            super().__init__(spacing=MARGIN)
            self.set_border_width(12)
            self.title = _("Tags")
            self._songs = []

            f = qltk.Frame(_("Tag Editing"), child=(self.tag_editing_vbox()))
            self.pack_start(f, False, True, TOP_MARGIN)

            f = qltk.Frame(_("Ratings"), child=self.ratings_vbox())
            self.pack_start(f, False, True, MARGIN)

            for child in self.get_children():
                child.show_all()

        def __changed(self, entry, section, name):
            config.set(section, name, entry.get_text())

        def __changed_and_signal_library(self, entry, section, name):
            config.set(section, name, str(entry.get_value()))
            print_d("Signalling \"changed\" to entire library. Hold tight...")
            # Cache over clicks
            self._songs = self._songs or list(app.library.values())
            copool.add(emit_signal, self._songs, funcid="library changed",
                       name=_("Updating for new ratings"))

    class Library(Gtk.VBox):
        name = "library"

        def __init__(self):
            super().__init__(spacing=MARGIN)
            self.set_border_width(12)
            self.title = _("Library")

            def refresh_cb(button):
                scan_library(app.library, force=False)

            refresh = qltk.Button(_("_Scan Library"), Icons.VIEW_REFRESH)
            refresh.connect("clicked", refresh_cb)
            refresh.set_tooltip_text(_("Check for changes in your library"))

            def reload_cb(button):
                scan_library(app.library, force=True)

            reload_ = qltk.Button(_("Re_build Library"), Icons.DOCUMENT_NEW)
            reload_.connect("clicked", reload_cb)
            reload_.set_tooltip_text(
                _("Reload all songs in your library. "
                  "This can take a long time."))

            vb = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
            hbox = Gtk.Box()
            hbox.pack_end(reload_, False, True, 0)
            hbox.pack_end(refresh, False, True, 12)
            vb.pack_start(hbox, False, True, 0)

            self.pack_start(self.create_behavior_frame(), False, False, TOP_MARGIN)
            self.pack_start(self.create_scandirs_frame(), False, True, MARGIN)

            # during testing
            if app.library is not None:
                masked = MaskedBox(app.library)
                f = qltk.Frame(_("Hidden Songs"), child=masked)
                self.pack_start(f, False, True, 12)

            for child in self.get_children():
                child.show_all()

        def create_behavior_frame(self):
            vb = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
            scan_at_start_sw = CS(_("Scan library _on start"),
                                  "library", "refresh_on_start", populate=True)
            req_restart = _("A restart is required for any changes to take effect")
            watch_lib_sw = CS(
                _("_Watch directories for changes"), "library", "watch", populate=True,
                tooltip=_("Watch library directories for external file additions, "
                          "deletions and renames.") + "\n" + req_restart)
            vb.pack_start(watch_lib_sw, False, True, 0)
            vb.pack_start(scan_at_start_sw, False, True, 0)
            return qltk.Frame(_("Behavior"), child=vb)

        def create_scandirs_frame(self):
            vb = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
            scan_dirs = ScanBox()
            vb.pack_start(scan_dirs, True, True, 0)
            f = qltk.Frame(_("Scan Directories"), child=vb)
            return f

    def __init__(self, parent, open_page=None, all_pages=True):
        if self.is_not_unique():
            return
        super().__init__()
        self.current_scan_dirs = get_scan_dirs()
        self.set_title(_("Preferences"))
        self.set_resizable(True)
        self.set_transient_for(qltk.get_top_parent(parent))

        self.__notebook = notebook = qltk.Notebook()
        add_css(notebook, "tab { padding: 6px 24px } ")
        pages = [self.Tagging]
        if all_pages:
            pages = [self.SongList, self.Browsers, self.Player, self.Library] + pages
        for Page in pages:
            page = Page()
            page.show()
            notebook.append_page(page)

        if open_page in [page.name for page in pages]:
            self.set_page(open_page)
        else:
            page_name = config.get("memory", "prefs_page", "")
            self.set_page(page_name)

        def on_switch_page(notebook, page, page_num):
            config.set("memory", "prefs_page", page.name)

        notebook.connect("switch-page", on_switch_page)

        close = Button(_("_Close"), Icons.WINDOW_CLOSE)
        connect_obj(close, 'clicked', lambda x: x.destroy(), self)
        button_box = Gtk.HButtonBox()
        button_box.set_layout(Gtk.ButtonBoxStyle.END)
        button_box.pack_start(close, True, True, 0)

        self.use_header_bar()
        if self.has_close_button():
            self.set_border_width(0)
            notebook.set_show_border(False)
            self.add(notebook)
        else:
            self.set_border_width(12)
            vbox = Gtk.VBox(spacing=12)
            vbox.pack_start(notebook, True, True, 0)
            vbox.pack_start(button_box, False, True, 0)
            self.add(vbox)

        connect_obj(self, 'destroy', PreferencesWindow.__destroy, self)

        self.get_child().show_all()

    def set_page(self, name):
        notebook = self.__notebook
        for p in range(notebook.get_n_pages()):
            if notebook.get_nth_page(p).name == name:
                notebook.set_current_page(p)

    def __destroy(self):
        config.save()
        new_dirs = set(get_scan_dirs())
        gone_dirs = set(self.current_scan_dirs) - new_dirs
        if new_dirs - set(self.current_scan_dirs):
            print_d("Library paths have been added, re-scanning...")
            scan_library(app.library, force=False)
        elif gone_dirs:
            copool.add(app.librarian.remove_roots, gone_dirs)


def create_grid(column_spacing: int = 12, row_spacing: int = 6):
    table = Gtk.Grid(row_spacing=row_spacing, column_spacing=column_spacing)
    return table


def hbox_for(label: Gtk.Label, entry: Gtk.Entry, expand_entry: bool = True) -> Gtk.Box:
    hb = Gtk.Box(spacing=12)
    hb.pack_start(label, False, False, 0)
    hb.pack_end(entry, expand_entry, True, 0)
    return hb
