luminate-old/Sources/LuminateUI/Components/WrapBox.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
}
}