Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ disabled_rules:
- file_length
- force_cast # Required a lot in backend implementations
- function_body_length
- function_parameter_count

line_length: 140
type_body_length: 400
Expand Down
135 changes: 118 additions & 17 deletions Examples/Sources/WindowingExample/WindowingApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import SwiftCrossUI
import SwiftBundlerRuntime
#endif

@available(tvOS, unavailable)
struct FileDialogDemo: View {
@State var selectedFile: URL? = nil
@State var saveDestination: URL? = nil
Expand Down Expand Up @@ -64,6 +65,98 @@ struct AlertDemo: View {
}
}

// A demo displaying SwiftCrossUI's `View.sheet` modifier.
struct SheetDemo: View {
@State var isPresented = false
@State var isEphemeralSheetPresented = false
@State var ephemeralSheetDismissalTask: Task<Void, Never>?

var body: some View {
Button("Open Sheet") {
isPresented = true
}
Button("Show Sheet for 5s") {
isEphemeralSheetPresented = true
ephemeralSheetDismissalTask = Task {
do {
try await Task.sleep(nanoseconds: 5 * 1_000_000_000)
isEphemeralSheetPresented = false
} catch {}
}
}
.sheet(isPresented: $isPresented) {
print("Root sheet dismissed")
} content: {
SheetBody()
.presentationDetents([.height(250), .medium, .large])
.presentationBackground(.green)
}
.sheet(isPresented: $isEphemeralSheetPresented) {
ephemeralSheetDismissalTask?.cancel()
} content: {
Text("I'm only here for 5s")
.padding(20)
.presentationDetents([.medium])
.presentationCornerRadius(10)
.presentationBackground(.red)
}
}

struct SheetBody: View {
@State var isNestedSheetPresented = false
@Environment(\.dismiss) var dismiss

var body: some View {
VStack {
Text("Root sheet")
Button("Present a nested sheet") {
isNestedSheetPresented = true
}
Button("Dismiss") {
dismiss()
}
}
.padding()
.sheet(isPresented: $isNestedSheetPresented) {
print("Nested sheet dismissed")
} content: {
NestedSheetBody(dismissRoot: { dismiss() })
.presentationDetents([.height(250), .medium, .large])
}
}
}

struct NestedSheetBody: View {
var dismissRoot: () -> Void

@Environment(\.dismiss) var dismiss
@State var showNextChild = false

var body: some View {
VStack {
Text("Nested sheet")

Button("Present another sheet") {
showNextChild = true
}
Button("Dismiss root sheet") {
dismissRoot()
}
Button("Dismiss") {
dismiss()
}
}
.padding()
.sheet(isPresented: $showNextChild) {
print("Nested sheet dismissed")
} content: {
NestedSheetBody(dismissRoot: dismissRoot)
.presentationDetents([.height(250), .medium, .large])
}
}
}
}

@main
@HotReloadable
struct WindowingApp: App {
Expand All @@ -87,11 +180,18 @@ struct WindowingApp: App {

Divider()

FileDialogDemo()
#if !os(tvOS)
FileDialogDemo()

Divider()
Divider()
#endif

AlertDemo()

Divider()

SheetDemo()
.padding(.bottom, 20)
}
.padding(20)
}
Expand All @@ -108,23 +208,24 @@ struct WindowingApp: App {
}
}
}

WindowGroup("Secondary window") {
#hotReloadable {
Text("This a secondary window!")
.padding(10)
#if !os(iOS) && !os(tvOS)
WindowGroup("Secondary window") {
#hotReloadable {
Text("This a secondary window!")
.padding(10)
}
}
}
.defaultSize(width: 200, height: 200)
.windowResizability(.contentMinSize)
.defaultSize(width: 200, height: 200)
.windowResizability(.contentMinSize)

WindowGroup("Tertiary window") {
#hotReloadable {
Text("This a tertiary window!")
.padding(10)
WindowGroup("Tertiary window") {
#hotReloadable {
Text("This a tertiary window!")
.padding(10)
}
}
}
.defaultSize(width: 200, height: 200)
.windowResizability(.contentMinSize)
.defaultSize(width: 200, height: 200)
.windowResizability(.contentMinSize)
#endif
}
}
119 changes: 119 additions & 0 deletions Sources/AppKitBackend/AppKitBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public final class AppKitBackend: AppBackend {
public typealias Menu = NSMenu
public typealias Alert = NSAlert
public typealias Path = NSBezierPath
public typealias Sheet = NSCustomSheet

public let defaultTableRowContentHeight = 20
public let defaultTableCellVerticalPadding = 4
Expand Down Expand Up @@ -1689,6 +1690,119 @@ public final class AppKitBackend: AppBackend {
let request = URLRequest(url: url)
webView.load(request)
}

public func createSheet(content: NSView) -> NSCustomSheet {
// Initialize with a default contentRect, similar to `createWindow`
let sheet = NSCustomSheet(
contentRect: NSRect(
x: 0,
y: 0,
width: 400,
height: 400
),
styleMask: [.titled, .closable],
backing: .buffered,
defer: true
)

let backgroundView = NSView()
backgroundView.translatesAutoresizingMaskIntoConstraints = false
backgroundView.wantsLayer = true

let contentView = NSView()
contentView.addSubview(backgroundView)
contentView.addSubview(content)
NSLayoutConstraint.activate([
contentView.topAnchor.constraint(equalTo: content.topAnchor),
contentView.leadingAnchor.constraint(equalTo: content.leadingAnchor),
contentView.bottomAnchor.constraint(equalTo: content.bottomAnchor),
contentView.trailingAnchor.constraint(equalTo: content.trailingAnchor),
contentView.topAnchor.constraint(equalTo: backgroundView.topAnchor),
contentView.leadingAnchor.constraint(equalTo: backgroundView.leadingAnchor),
contentView.bottomAnchor.constraint(equalTo: backgroundView.bottomAnchor),
contentView.trailingAnchor.constraint(equalTo: backgroundView.trailingAnchor),
])
contentView.translatesAutoresizingMaskIntoConstraints = false

sheet.contentView = contentView
sheet.backgroundView = backgroundView

return sheet
}

public func updateSheet(
_ sheet: NSCustomSheet,
window: NSCustomWindow,
environment: EnvironmentValues,
size: SIMD2<Int>,
onDismiss: @escaping () -> Void,
cornerRadius: Double?,
detents: [PresentationDetent],
dragIndicatorVisibility: Visibility,
backgroundColor: Color?,
interactiveDismissDisabled: Bool
) {
sheet.setContentSize(NSSize(width: size.x, height: size.y))
sheet.onDismiss = onDismiss

let background = sheet.backgroundView!
background.layer?.backgroundColor = backgroundColor?.nsColor.cgColor
sheet.interactiveDismissDisabled = interactiveDismissDisabled

// - dragIndicatorVisibility is only for mobile so we ignore it
// - detents are only for mobile so we ignore them
// - cornerRadius isn't supported by macOS so we ignore it
}

public func size(ofSheet sheet: NSCustomSheet) -> SIMD2<Int> {
guard let size = sheet.contentView?.frame.size else {
return SIMD2(x: 0, y: 0)
}
return SIMD2(x: Int(size.width), y: Int(size.height))
}

public func presentSheet(_ sheet: NSCustomSheet, window: Window, parentSheet: Sheet?) {
let parent = parentSheet ?? window
// beginSheet and beginCriticalSheet should be equivalent here, because we
// directly present the sheet on top of the top-most sheet. If we were to
// instead present sheets on top of the root window every time, then
// beginCriticalSheet would produce the desired behaviour and beginSheet
// would wait for the parent sheet to finish before presenting the nested sheet.
parent.beginSheet(sheet)
parent.nestedSheet = sheet
}

public func dismissSheet(_ sheet: NSCustomSheet, window: Window, parentSheet: Sheet?) {
let parent = parentSheet ?? window

// Dismiss nested sheets first
if let nestedSheet = sheet.nestedSheet {
dismissSheet(nestedSheet, window: window, parentSheet: sheet)
// Although the current sheet has been dismissed programmatically,
// the nested sheets kind of haven't (at least, they weren't
// directly dismissed by SwiftCrossUI, so we must called onDismiss
// to let SwiftUI react to the dismissals of nested sheets).
nestedSheet.onDismiss?()
}

parent.endSheet(sheet)
parent.nestedSheet = nil
}
}

public final class NSCustomSheet: NSCustomWindow, NSWindowDelegate {
public var onDismiss: (() -> Void)?

public var interactiveDismissDisabled: Bool = false

public var backgroundView: NSView?

@objc override public func cancelOperation(_ sender: Any?) {
if !interactiveDismissDisabled {
sheetParent?.endSheet(self)
onDismiss?()
}
}
}

final class NSCustomTapGestureTarget: NSView {
Expand Down Expand Up @@ -2111,6 +2225,11 @@ public class NSCustomWindow: NSWindow {
var customDelegate = Delegate()
var persistentUndoManager = UndoManager()

/// A reference to the sheet currently presented on top of this window, if any.
/// If the sheet itself has another sheet presented on top of it, then that doubly
/// nested sheet gets stored as the sheet's nestedSheet, and so on.
var nestedSheet: NSCustomSheet?

/// Allows the backing scale factor to be overridden. Useful for keeping
/// UI tests consistent across devices.
///
Expand Down
77 changes: 77 additions & 0 deletions Sources/Gtk/Generated/EventControllerKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import CGtk

/// Provides access to key events.
open class EventControllerKey: EventController {
/// Creates a new event controller that will handle key events.
public convenience init() {
self.init(
gtk_event_controller_key_new()
)
}

public override func registerSignals() {
super.registerSignals()

addSignal(name: "im-update") { [weak self] () in
guard let self = self else { return }
self.imUpdate?(self)
}

let handler1:
@convention(c) (
UnsafeMutableRawPointer, UInt, UInt, GdkModifierType, UnsafeMutableRawPointer
) -> Void =
{ _, value1, value2, value3, data in
SignalBox3<UInt, UInt, GdkModifierType>.run(data, value1, value2, value3)
}

addSignal(name: "key-pressed", handler: gCallback(handler1)) {
[weak self] (param0: UInt, param1: UInt, param2: GdkModifierType) in
guard let self = self else { return }
self.keyPressed?(self, param0, param1, param2)
}

let handler2:
@convention(c) (
UnsafeMutableRawPointer, UInt, UInt, GdkModifierType, UnsafeMutableRawPointer
) -> Void =
{ _, value1, value2, value3, data in
SignalBox3<UInt, UInt, GdkModifierType>.run(data, value1, value2, value3)
}

addSignal(name: "key-released", handler: gCallback(handler2)) {
[weak self] (param0: UInt, param1: UInt, param2: GdkModifierType) in
guard let self = self else { return }
self.keyReleased?(self, param0, param1, param2)
}

let handler3:
@convention(c) (UnsafeMutableRawPointer, GdkModifierType, UnsafeMutableRawPointer) ->
Void =
{ _, value1, data in
SignalBox1<GdkModifierType>.run(data, value1)
}

addSignal(name: "modifiers", handler: gCallback(handler3)) {
[weak self] (param0: GdkModifierType) in
guard let self = self else { return }
self.modifiers?(self, param0)
}
}

/// Emitted whenever the input method context filters away
/// a keypress and prevents the @controller receiving it.
///
/// See [method@Gtk.EventControllerKey.set_im_context] and
/// [method@Gtk.IMContext.filter_keypress].
public var imUpdate: ((EventControllerKey) -> Void)?

/// Emitted whenever a key is pressed.
public var keyPressed: ((EventControllerKey, UInt, UInt, GdkModifierType) -> Void)?

/// Emitted whenever a key is released.
public var keyReleased: ((EventControllerKey, UInt, UInt, GdkModifierType) -> Void)?

/// Emitted whenever the state of modifier keys and pointer buttons change.
public var modifiers: ((EventControllerKey, GdkModifierType) -> Void)?
}
Loading
Loading