232 lines
7.3 KiB
Swift
232 lines
7.3 KiB
Swift
//
|
||
// FlowGrid.swift
|
||
//
|
||
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
|
||
//
|
||
// 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 3 of the License, or
|
||
// (at your option) any later version.
|
||
//
|
||
// 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/>.
|
||
//
|
||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||
//
|
||
|
||
import Adwaita
|
||
import CGtkWidgets
|
||
|
||
/// Controls how the last row is aligned when it doesn't fill the width.
|
||
public enum FlowGridJustifySetting: Int {
|
||
|
||
/// Last row items are aligned to the start.
|
||
case start
|
||
/// Last row items are centered.
|
||
case center
|
||
/// Last row items are aligned to the end.
|
||
case end
|
||
|
||
var cValue: FlowGridJustify {
|
||
switch self {
|
||
case .start: FLOW_GRID_JUSTIFY_START
|
||
case .center: FLOW_GRID_JUSTIFY_CENTER
|
||
case .end: FLOW_GRID_JUSTIFY_END
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
/// A reflowing grid that arranges children in columns.
|
||
///
|
||
/// Unlike `GtkFlowBox`, this widget re‑measures children during every layout
|
||
/// pass using the actual column width. This avoids stale size caches and
|
||
/// ensures the `AspectContainer` receives the correct `for_size` for its
|
||
/// height computation.
|
||
///
|
||
/// ```swift
|
||
/// FlowGrid(items, id: \.id) { item in
|
||
/// HomePosterCell(item: item, navigation: $navigation)
|
||
/// }
|
||
/// .columnSpacing(16)
|
||
/// .rowSpacing(16)
|
||
/// .halign(.fill)
|
||
/// ```
|
||
public struct FlowGrid<Element, Identifier>: Widget where Identifier: Hashable {
|
||
|
||
#if exposeGeneratedAppearUpdateFunctions
|
||
public var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = []
|
||
public var appearFunctions: [(ViewStorage, WidgetData) -> Void] = []
|
||
#else
|
||
var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = []
|
||
var appearFunctions: [(ViewStorage, WidgetData) -> Void] = []
|
||
#endif
|
||
|
||
/// The minimum column width.
|
||
var minimumSize: Int = 200
|
||
/// The spacing between columns.
|
||
var columnSpacing: Int = 0
|
||
/// The spacing between rows.
|
||
var rowSpacing: Int = 0
|
||
/// How the last row is aligned.
|
||
var justify: FlowGridJustifySetting = .start
|
||
|
||
/// The elements to display.
|
||
var elements: [Element]
|
||
/// The content builder.
|
||
var content: (Element) -> Body
|
||
/// The identifier key path.
|
||
var id: KeyPath<Element, Identifier>
|
||
|
||
/// Initialize `FlowGrid`.
|
||
public init(
|
||
_ elements: [Element],
|
||
id: KeyPath<Element, Identifier>,
|
||
@ViewBuilder content: @escaping (Element) -> Body
|
||
) {
|
||
self.elements = elements
|
||
self.content = content
|
||
self.id = id
|
||
}
|
||
|
||
// MARK: - Widget
|
||
|
||
public func container<Data>(data: WidgetData, type: Data.Type) -> ViewStorage
|
||
where Data: ViewRenderData {
|
||
let storage = ViewStorage(
|
||
flow_grid_new(minimumSize.cInt, columnSpacing.cInt, rowSpacing.cInt)?.opaque()
|
||
)
|
||
for function in appearFunctions {
|
||
function(storage, data)
|
||
}
|
||
return storage
|
||
}
|
||
|
||
public func update<Data>(
|
||
_ storage: ViewStorage,
|
||
data: WidgetData,
|
||
updateProperties: Bool,
|
||
type: Data.Type
|
||
) where Data: ViewRenderData {
|
||
storage.modify { widget in
|
||
|
||
// --- Properties ---
|
||
if updateProperties,
|
||
(storage.previousState as? Self)?.minimumSize != minimumSize
|
||
{
|
||
flow_grid_set_minimum_size(widget, minimumSize.cInt)
|
||
}
|
||
if updateProperties,
|
||
(storage.previousState as? Self)?.columnSpacing != columnSpacing
|
||
{
|
||
flow_grid_set_column_spacing(widget, columnSpacing.cInt)
|
||
}
|
||
if updateProperties,
|
||
(storage.previousState as? Self)?.rowSpacing != rowSpacing
|
||
{
|
||
flow_grid_set_row_spacing(widget, rowSpacing.cInt)
|
||
}
|
||
if updateProperties,
|
||
(storage.previousState as? Self)?.justify != justify
|
||
{
|
||
flow_grid_set_justify(widget, justify.cValue)
|
||
}
|
||
|
||
// --- Children ---
|
||
var contentStorage: [ViewStorage] = storage.content[.mainContent] ?? []
|
||
let oldElements = storage.fields["element"] as? [Element] ?? []
|
||
|
||
var oldByID: [Identifier: ViewStorage] = [:]
|
||
for (i, oldElement) in oldElements.enumerated() where i < contentStorage.count {
|
||
oldByID[oldElement[keyPath: id]] = contentStorage[i]
|
||
}
|
||
|
||
var newContentStorage: [ViewStorage] = []
|
||
var lastChild: OpaquePointer?
|
||
|
||
for element in elements {
|
||
let elementID = element[keyPath: id]
|
||
if let existingStorage = oldByID.removeValue(forKey: elementID) {
|
||
newContentStorage.append(existingStorage)
|
||
lastChild = existingStorage.opaquePointer
|
||
} else {
|
||
let child = content(element).storage(data: data, type: type)
|
||
gtk_widget_set_parent(child.opaquePointer?.cast(), widget?.cast())
|
||
newContentStorage.append(child)
|
||
lastChild = child.opaquePointer
|
||
}
|
||
}
|
||
|
||
for (_, staleStorage) in oldByID {
|
||
gtk_widget_unparent(staleStorage.opaquePointer?.cast())
|
||
}
|
||
|
||
storage.fields["element"] = elements
|
||
storage.content[.mainContent] = newContentStorage
|
||
|
||
for (index, element) in elements.enumerated() {
|
||
content(element).updateStorage(
|
||
newContentStorage[index],
|
||
data: data,
|
||
updateProperties: updateProperties,
|
||
type: type
|
||
)
|
||
}
|
||
}
|
||
|
||
for function in updateFunctions {
|
||
function(storage, data, updateProperties)
|
||
}
|
||
if updateProperties {
|
||
storage.previousState = self
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// MARK: - Modifiers
|
||
|
||
extension FlowGrid {
|
||
|
||
/// The minimum column width in pixels.
|
||
public func minimumSize(_ minimumSize: Int) -> Self {
|
||
modify { $0.minimumSize = minimumSize }
|
||
}
|
||
|
||
/// The spacing between columns in pixels.
|
||
public func columnSpacing(_ columnSpacing: Int) -> Self {
|
||
modify { $0.columnSpacing = columnSpacing }
|
||
}
|
||
|
||
/// The spacing between rows in pixels.
|
||
public func rowSpacing(_ rowSpacing: Int) -> Self {
|
||
modify { $0.rowSpacing = rowSpacing }
|
||
}
|
||
|
||
/// How the last row is aligned.
|
||
public func justify(_ justify: FlowGridJustifySetting) -> Self {
|
||
modify { $0.justify = justify }
|
||
}
|
||
|
||
}
|
||
|
||
// MARK: - Convenience Initializer for Identifiable Elements
|
||
|
||
extension FlowGrid where Element: Identifiable, Identifier == Element.ID {
|
||
|
||
/// Initialize `FlowGrid` with identifiable elements.
|
||
public init(
|
||
_ elements: [Element],
|
||
@ViewBuilder content: @escaping (Element) -> Body
|
||
) {
|
||
self.elements = elements
|
||
self.content = content
|
||
self.id = \.id
|
||
}
|
||
|
||
}
|