430 lines
14 KiB
Swift
430 lines
14 KiB
Swift
//
|
|
// WrapBox.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 CAdw
|
|
|
|
// MARK: - Swift Enum Wrappers for C Enums
|
|
|
|
/// Controls how children are justified within each line.
|
|
public enum JustifyMode {
|
|
|
|
/// No justification.
|
|
case none
|
|
/// Children are stretched to fill the line.
|
|
case fill
|
|
/// Extra space is distributed evenly between children.
|
|
case spread
|
|
|
|
var cValue: AdwJustifyMode {
|
|
switch self {
|
|
case .none: ADW_JUSTIFY_NONE
|
|
case .fill: ADW_JUSTIFY_FILL
|
|
case .spread: ADW_JUSTIFY_SPREAD
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
/// Controls the packing direction.
|
|
public enum PackDirection {
|
|
|
|
/// Children are packed from start to end.
|
|
case startToEnd
|
|
/// Children are packed from end to start.
|
|
case endToStart
|
|
|
|
var cValue: AdwPackDirection {
|
|
switch self {
|
|
case .startToEnd: ADW_PACK_START_TO_END
|
|
case .endToStart: ADW_PACK_END_TO_START
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
/// Controls the wrapping policy.
|
|
public enum WrapPolicy {
|
|
|
|
/// Wrapping occurs at the minimum size.
|
|
case minimum
|
|
/// Wrapping occurs at the natural size.
|
|
case natural
|
|
|
|
var cValue: AdwWrapPolicy {
|
|
switch self {
|
|
case .minimum: ADW_WRAP_MINIMUM
|
|
case .natural: ADW_WRAP_NATURAL
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
/// Units for spacing and length properties.
|
|
public enum LengthUnit {
|
|
|
|
/// Pixels.
|
|
case px
|
|
/// Points.
|
|
case pt
|
|
/// Scale-independent pixels.
|
|
case sp
|
|
|
|
var cValue: AdwLengthUnit {
|
|
switch self {
|
|
case .px: ADW_LENGTH_UNIT_PX
|
|
case .pt: ADW_LENGTH_UNIT_PT
|
|
case .sp: ADW_LENGTH_UNIT_SP
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - WrapBox Widget
|
|
|
|
/// A responsive wrapping container that arranges children in a reflowing grid.
|
|
///
|
|
/// `WrapBox` places its children in a horizontal flow, wrapping to the next
|
|
/// line when the available width is exhausted. It is backed by `AdwWrapBox` from libadwaita.
|
|
///
|
|
/// Use inside a `ScrollView` to allow shrinking in both axes.
|
|
///
|
|
/// ```swift
|
|
/// ScrollView {
|
|
/// WrapBox(items) { item in
|
|
/// ItemCell(item: item)
|
|
/// }
|
|
/// .childSpacing(12)
|
|
/// .lineSpacing(12)
|
|
/// .lineHomogeneous(true)
|
|
/// }
|
|
/// ```
|
|
public struct WrapBox<Element, Identifier>: AdwaitaWidget where Identifier: Hashable {
|
|
|
|
#if exposeGeneratedAppearUpdateFunctions
|
|
/// Additional update functions for type extensions.
|
|
public var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = []
|
|
/// Additional appear functions for type extensions.
|
|
public var appearFunctions: [(ViewStorage, WidgetData) -> Void] = []
|
|
#else
|
|
/// Additional update functions for type extensions.
|
|
var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = []
|
|
/// Additional appear functions for type extensions.
|
|
var appearFunctions: [(ViewStorage, WidgetData) -> Void] = []
|
|
#endif
|
|
|
|
/// The amount of space between children.
|
|
var childSpacing: Int?
|
|
/// The unit for `childSpacing`.
|
|
var childSpacingUnit: LengthUnit?
|
|
/// The packing direction.
|
|
var packDirection: PackDirection?
|
|
/// The alignment of children within each line (0.0 to 1.0).
|
|
var align: Float?
|
|
/// The justification mode.
|
|
var justify: JustifyMode?
|
|
/// Whether to justify the last line.
|
|
var justifyLastLine: Bool?
|
|
/// The amount of space between lines.
|
|
var lineSpacing: Int?
|
|
/// The unit for `lineSpacing`.
|
|
var lineSpacingUnit: LengthUnit?
|
|
/// Whether all lines should be the same size.
|
|
var lineHomogeneous: Bool?
|
|
/// The natural length of each line.
|
|
var naturalLineLength: Int?
|
|
/// The unit for `naturalLineLength`.
|
|
var naturalLineLengthUnit: LengthUnit?
|
|
/// Whether to reverse the wrapping direction.
|
|
var wrapReverse: Bool?
|
|
/// The wrapping policy.
|
|
var wrapPolicy: WrapPolicy?
|
|
|
|
/// The dynamic widget elements.
|
|
var elements: [Element]
|
|
/// The dynamic widget content.
|
|
var content: (Element) -> Body
|
|
/// The dynamic widget identifier key path.
|
|
var id: KeyPath<Element, Identifier>
|
|
|
|
/// Initialize `WrapBox`.
|
|
/// - Parameters:
|
|
/// - elements: The elements to display.
|
|
/// - id: The key path to the element's identifier.
|
|
/// - content: A view builder for rendering each element.
|
|
public init(
|
|
_ elements: [Element],
|
|
id: KeyPath<Element, Identifier>,
|
|
@ViewBuilder content: @escaping (Element) -> Body
|
|
) {
|
|
self.elements = elements
|
|
self.content = content
|
|
self.id = id
|
|
}
|
|
|
|
// MARK: - AdwaitaWidget
|
|
|
|
public func container<Data>(data: WidgetData, type: Data.Type) -> ViewStorage
|
|
where Data: ViewRenderData {
|
|
let storage = ViewStorage(adw_wrap_box_new()?.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
|
|
|
|
// --- Apply property changes ---
|
|
if let childSpacing, updateProperties,
|
|
(storage.previousState as? Self)?.childSpacing != childSpacing
|
|
{
|
|
adw_wrap_box_set_child_spacing(widget, childSpacing.cInt)
|
|
}
|
|
if let childSpacingUnit, updateProperties,
|
|
(storage.previousState as? Self)?.childSpacingUnit != childSpacingUnit
|
|
{
|
|
adw_wrap_box_set_child_spacing_unit(widget, childSpacingUnit.cValue)
|
|
}
|
|
if let packDirection, updateProperties,
|
|
(storage.previousState as? Self)?.packDirection != packDirection
|
|
{
|
|
adw_wrap_box_set_pack_direction(widget, packDirection.cValue)
|
|
}
|
|
if let align, updateProperties,
|
|
(storage.previousState as? Self)?.align != align
|
|
{
|
|
adw_wrap_box_set_align(widget, align)
|
|
}
|
|
if let justify, updateProperties,
|
|
(storage.previousState as? Self)?.justify != justify
|
|
{
|
|
adw_wrap_box_set_justify(widget, justify.cValue)
|
|
}
|
|
if let justifyLastLine, updateProperties,
|
|
(storage.previousState as? Self)?.justifyLastLine != justifyLastLine
|
|
{
|
|
adw_wrap_box_set_justify_last_line(widget, justifyLastLine.cBool)
|
|
}
|
|
if let lineSpacing, updateProperties,
|
|
(storage.previousState as? Self)?.lineSpacing != lineSpacing
|
|
{
|
|
adw_wrap_box_set_line_spacing(widget, lineSpacing.cInt)
|
|
}
|
|
if let lineSpacingUnit, updateProperties,
|
|
(storage.previousState as? Self)?.lineSpacingUnit != lineSpacingUnit
|
|
{
|
|
adw_wrap_box_set_line_spacing_unit(widget, lineSpacingUnit.cValue)
|
|
}
|
|
if let lineHomogeneous, updateProperties,
|
|
(storage.previousState as? Self)?.lineHomogeneous != lineHomogeneous
|
|
{
|
|
adw_wrap_box_set_line_homogeneous(widget, lineHomogeneous.cBool)
|
|
}
|
|
if let naturalLineLength, updateProperties,
|
|
(storage.previousState as? Self)?.naturalLineLength != naturalLineLength
|
|
{
|
|
adw_wrap_box_set_natural_line_length(widget, naturalLineLength.cInt)
|
|
}
|
|
if let naturalLineLengthUnit, updateProperties,
|
|
(storage.previousState as? Self)?.naturalLineLengthUnit != naturalLineLengthUnit
|
|
{
|
|
adw_wrap_box_set_natural_line_length_unit(widget, naturalLineLengthUnit.cValue)
|
|
}
|
|
if let wrapReverse, updateProperties,
|
|
(storage.previousState as? Self)?.wrapReverse != wrapReverse
|
|
{
|
|
adw_wrap_box_set_wrap_reverse(widget, wrapReverse.cBool)
|
|
}
|
|
if let wrapPolicy, updateProperties,
|
|
(storage.previousState as? Self)?.wrapPolicy != wrapPolicy
|
|
{
|
|
adw_wrap_box_set_wrap_policy(widget, wrapPolicy.cValue)
|
|
}
|
|
|
|
// --- Child management ---
|
|
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)
|
|
if let lastPtr = lastChild {
|
|
adw_wrap_box_reorder_child_after(
|
|
widget,
|
|
existingStorage.opaquePointer?.cast(),
|
|
lastPtr.cast()
|
|
)
|
|
} else {
|
|
adw_wrap_box_reorder_child_after(
|
|
widget,
|
|
existingStorage.opaquePointer?.cast(),
|
|
nil
|
|
)
|
|
}
|
|
lastChild = existingStorage.opaquePointer
|
|
} else {
|
|
let child = content(element).storage(data: data, type: type)
|
|
if let lastPtr = lastChild {
|
|
adw_wrap_box_insert_child_after(
|
|
widget,
|
|
child.opaquePointer?.cast(),
|
|
lastPtr.cast()
|
|
)
|
|
} else {
|
|
adw_wrap_box_prepend(widget, child.opaquePointer?.cast())
|
|
}
|
|
newContentStorage.append(child)
|
|
lastChild = child.opaquePointer
|
|
}
|
|
}
|
|
|
|
for (_, staleStorage) in oldByID {
|
|
adw_wrap_box_remove(widget, 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: - Modifier Methods
|
|
|
|
extension WrapBox {
|
|
|
|
/// The amount of space between children.
|
|
public func childSpacing(_ childSpacing: Int?) -> Self {
|
|
modify { $0.childSpacing = childSpacing }
|
|
}
|
|
|
|
/// The unit for `childSpacing`.
|
|
public func childSpacingUnit(_ childSpacingUnit: LengthUnit?) -> Self {
|
|
modify { $0.childSpacingUnit = childSpacingUnit }
|
|
}
|
|
|
|
/// The packing direction.
|
|
public func packDirection(_ packDirection: PackDirection?) -> Self {
|
|
modify { $0.packDirection = packDirection }
|
|
}
|
|
|
|
/// The alignment of children within each line (0.0 to 1.0).
|
|
public func align(_ align: Float?) -> Self {
|
|
modify { $0.align = align }
|
|
}
|
|
|
|
/// The justification mode.
|
|
public func justify(_ justify: JustifyMode?) -> Self {
|
|
modify { $0.justify = justify }
|
|
}
|
|
|
|
/// Whether to justify the last line.
|
|
public func justifyLastLine(_ justifyLastLine: Bool? = true) -> Self {
|
|
modify { $0.justifyLastLine = justifyLastLine }
|
|
}
|
|
|
|
/// The amount of space between lines.
|
|
public func lineSpacing(_ lineSpacing: Int?) -> Self {
|
|
modify { $0.lineSpacing = lineSpacing }
|
|
}
|
|
|
|
/// The unit for `lineSpacing`.
|
|
public func lineSpacingUnit(_ lineSpacingUnit: LengthUnit?) -> Self {
|
|
modify { $0.lineSpacingUnit = lineSpacingUnit }
|
|
}
|
|
|
|
/// Whether all lines should be the same size.
|
|
public func lineHomogeneous(_ lineHomogeneous: Bool? = true) -> Self {
|
|
modify { $0.lineHomogeneous = lineHomogeneous }
|
|
}
|
|
|
|
/// The natural length of each line.
|
|
public func naturalLineLength(_ naturalLineLength: Int?) -> Self {
|
|
modify { $0.naturalLineLength = naturalLineLength }
|
|
}
|
|
|
|
/// The unit for `naturalLineLength`.
|
|
public func naturalLineLengthUnit(_ naturalLineLengthUnit: LengthUnit?) -> Self {
|
|
modify { $0.naturalLineLengthUnit = naturalLineLengthUnit }
|
|
}
|
|
|
|
/// Whether to reverse the wrapping direction.
|
|
public func wrapReverse(_ wrapReverse: Bool? = true) -> Self {
|
|
modify { $0.wrapReverse = wrapReverse }
|
|
}
|
|
|
|
/// The wrapping policy.
|
|
public func wrapPolicy(_ wrapPolicy: WrapPolicy?) -> Self {
|
|
modify { $0.wrapPolicy = wrapPolicy }
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Convenience Initializer for Identifiable Elements
|
|
|
|
extension WrapBox where Element: Identifiable, Identifier == Element.ID {
|
|
|
|
/// Initialize `WrapBox` with identifiable elements.
|
|
/// - Parameters:
|
|
/// - elements: The identifiable elements to display.
|
|
/// - content: A view builder for rendering each element.
|
|
public init(
|
|
_ elements: [Element],
|
|
@ViewBuilder content: @escaping (Element) -> Body
|
|
) {
|
|
self.elements = elements
|
|
self.content = content
|
|
self.id = \.id
|
|
}
|
|
|
|
}
|