Skip to content

Commit

Permalink
Merge pull request #31 from fwcd/navigation
Browse files Browse the repository at this point in the history
Add navigation views
  • Loading branch information
fwcd authored Sep 9, 2024
2 parents e8fc62d + 564a742 commit b29e54a
Show file tree
Hide file tree
Showing 49 changed files with 752 additions and 80 deletions.
51 changes: 51 additions & 0 deletions examples/navigation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#![feature(type_alias_impl_trait, impl_trait_in_assoc_type)]

use nuit::{prelude::*, List, NavigationLink, NavigationSplitView, NavigationStack, Text, VStack};

#[derive(Bind, Default)]
struct NavigationContent {
i: i32,
}

impl NavigationContent {
pub const fn new(i: i32) -> Self {
Self { i }
}
}

impl View for NavigationContent {
type Body = impl View;

fn body(&self) -> Self::Body {
let i = self.i;
VStack::from((
Text::new(format!("This is page {i}")),
NavigationLink::with_text("Next", i + 1),
))
.navigation_destination(Self::new)
}
}

#[derive(Bind)]
struct NavigationView;

impl View for NavigationView {
type Body = impl View;

fn body(&self) -> Self::Body {
NavigationSplitView::with_sidebar(
List::from((
Text::new("Hello"),
Text::new("World"),
))
).with_detail(
NavigationStack::from(
NavigationContent::new(0)
)
)
}
}

fn main() {
nuit::run_app(NavigationView);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
struct CRoot {
void *wrapped;
const char *(*render_json)(const struct CRoot *);
void (*fire_event_json)(const struct CRoot *, const char *, const char *);
const char *(*fire_event_json)(const struct CRoot *, const char *, const char *);
void (*set_update_callback)(const struct CRoot *, void (*)(const char *));
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ enum Event: Codable, Hashable {
case updateText(content: String)
case updatePickerSelection(id: Id)
case updateSliderValue(value: Double)
case updateNavigationPath(path: [Value])
case getNavigationDestination(value: Value)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import NuitBridgeSwiftUICore

enum EventResponse: Codable, Hashable {
case empty
case node(node: Identified<Node>)
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ enum ModifierNode: Codable, Hashable {
case scaleEffect(factor: Double, anchor: UnitPoint)
case rotationEffect(angle: Angle, anchor: UnitPoint)
case help(text: String)
case navigationTitle(title: String)
case navigationSubtitle(subtitle: String)
case navigationTitleDisplayMode(displayMode: NavigationTitleDisplayMode)
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@ struct ModifierNodeViewModifier: ViewModifier {
content.rotationEffect(.init(angle), anchor: .init(anchor))
case let .help(text: text):
content.help(text)
case let .navigationTitle(title: title):
content.navigationTitle(title)
case let .navigationSubtitle(subtitle: subtitle):
#if os(macOS) || targetEnvironment(macCatalyst)
content.navigationSubtitle(subtitle)
#else
content
#endif
case let .navigationTitleDisplayMode(displayMode: displayMode):
#if !os(macOS)
content.navigationBarTitleDisplayMode(.init(displayMode))
#else
content
#endif
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions nuit-bridge-swiftui/Sources/NuitBridgeSwiftUI/Node.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,18 @@ indirect enum Node: Codable, Hashable {
case list(wrapped: Identified<Node>)
case overlay(wrapped: Identified<Node>, alignment: Alignment, overlayed: Identified<Node>)

// MARK: Navigation
case navigationStack(path: [Value]?, wrapped: Identified<Node>)
case navigationSplitView(sidebar: Identified<Node>, content: Identified<Node>, detail: Identified<Node>)
case navigationLink(label: Identified<Node>, value: Value)
case navigationDestination(wrapped: Identified<Node>)

// MARK: Wrapper
case shape(shape: ShapeNode)
case gestured(wrapped: Identified<Node>, gesture: Identified<GestureNode>)
case modified(wrapped: Identified<Node>, modifier: ModifierNode)

var isEmpty: Bool {
self == .empty
}
}
42 changes: 42 additions & 0 deletions nuit-bridge-swiftui/Sources/NuitBridgeSwiftUI/NodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,48 @@ struct NodeView: View {
.overlay(alignment: .init(alignment)) {
NodeView(node: overlayed.value, idPath: idPath + [overlayed.id])
}

// MARK: Navigation
case let .navigationStack(path: path, wrapped: wrapped):
if let path {
NavigationStack(path: Binding(
get: { path },
set: { root.fire(event: .updateNavigationPath(path: $0), for: idPath) }
)) {
NodeView(node: wrapped.value, idPath: idPath + [wrapped.id])
}
} else {
NavigationStack {
NodeView(node: wrapped.value, idPath: idPath + [wrapped.id])
}
}
case let .navigationSplitView(sidebar: sidebar, content: content, detail: detail):
if content.value.isEmpty {
NavigationSplitView {
NodeView(node: sidebar.value, idPath: idPath + [sidebar.id])
} detail: {
NodeView(node: detail.value, idPath: idPath + [detail.id])
}
} else {
NavigationSplitView {
NodeView(node: sidebar.value, idPath: idPath + [sidebar.id])
} content: {
NodeView(node: content.value, idPath: idPath + [content.id])
} detail: {
NodeView(node: detail.value, idPath: idPath + [detail.id])
}
}
case let .navigationLink(label: label, value: value):
NavigationLink(value: value) {
NodeView(node: label.value, idPath: idPath + [label.id])
}
case let .navigationDestination(wrapped: wrapped):
NodeView(node: wrapped.value, idPath: idPath + [wrapped.id])
.navigationDestination(for: Value.self) { value in
if case let .node(node: destination) = root.fire(event: .getNavigationDestination(value: value), for: idPath) {
NodeView(node: destination.value, idPath: idPath + [destination.id])
}
}

// MARK: Wrapper
case let .shape(shape: shape):
Expand Down
34 changes: 23 additions & 11 deletions nuit-bridge-swiftui/Sources/NuitBridgeSwiftUI/Root.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,6 @@ class Root: ObservableObject {
/// A manually installed publisher since we don't use `@Published`.
var objectWillChange = ObservableObjectPublisher()

/// The rendered root node.
var node: Node {
let json = renderJson()
let node = try! JSONDecoder().decode(Node.self, from: json.data(using: .utf8)!)
return node
}

init(cRoot: UnsafePointer<CRoot>) {
self.cRoot = cRoot
}
Expand All @@ -28,16 +21,35 @@ class Root: ObservableObject {
}
}

func fire(event: Event, for idPath: [Id]) {
// MARK: High-level FFI wrappers

func render() -> Node {
let json = renderJson()
let node = try! JSONDecoder().decode(Node.self, from: json.data(using: .utf8)!)
return node
}

@discardableResult
func fire(event: Event, for idPath: [Id]) -> EventResponse {
let encoder = JSONEncoder()
let idPathJson = String(data: try! encoder.encode(idPath), encoding: .utf8)
let eventJson = String(data: try! encoder.encode(event), encoding: .utf8)
cRoot.pointee.fire_event_json(cRoot, idPathJson, eventJson)
let idPathJson = String(data: try! encoder.encode(idPath), encoding: .utf8)!
let eventJson = String(data: try! encoder.encode(event), encoding: .utf8)!
let responseJson = fire(eventJson: eventJson, for: idPathJson)
return try! JSONDecoder().decode(EventResponse.self, from: responseJson.data(using: .utf8)!)
}

// MARK: JSON FFI wrappers

private func renderJson() -> String {
let cString = cRoot.pointee.render_json(cRoot)!
defer { nuit_c_string_drop(cString) }
return String(cString: cString)
}

@discardableResult
private func fire(eventJson: String, for idPathJson: String) -> String {
let cString = cRoot.pointee.fire_event_json(cRoot, idPathJson, eventJson)!
defer { nuit_c_string_drop(cString) }
return String(cString: cString)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ struct RootView: View {
@EnvironmentObject private var root: Root

var body: some View {
NodeView(node: root.node, idPath: [])
NodeView(node: root.render(), idPath: [])
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import SwiftUI

#if !os(macOS)
public extension NavigationBarItem.TitleDisplayMode {
init(_ displayMode: NavigationTitleDisplayMode) {
switch displayMode {
case .automatic: self = .automatic
case .inline: self = .inline
case .large: self = .large
}
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
public enum NavigationTitleDisplayMode: String, Codable, Hashable {
case automatic
case inline
case large
}
103 changes: 103 additions & 0 deletions nuit-bridge-swiftui/Sources/NuitBridgeSwiftUICore/Utils/Value.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/// An arbitrary JSON value.
public enum Value: Codable, Hashable {
case null
case bool(Bool)
case int(Int)
case double(Double)
case string(String)
case array([Value])
case object([String: Value])

public init(from decoder: any Decoder) throws {
if let value = try? [Value](from: decoder) {
self = .array(value)
} else if let value = try? [String: Value](from: decoder) {
self = .object(value)
} else {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Int.self) {
self = .int(value)
} else if let value = try? container.decode(Double.self) {
self = .double(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Could not decode JSON")
}
}
}

public func encode(to encoder: any Encoder) throws {
switch self {
case .null:
var container = encoder.singleValueContainer()
try container.encodeNil()
case .bool(let value):
var container = encoder.singleValueContainer()
try container.encode(value)
case .int(let value):
var container = encoder.singleValueContainer()
try container.encode(value)
case .double(let value):
var container = encoder.singleValueContainer()
try container.encode(value)
case .string(let value):
var container = encoder.singleValueContainer()
try container.encode(value)
case .array(let value):
var container = encoder.singleValueContainer()
try container.encode(value)
case .object(let value):
var container = encoder.singleValueContainer()
try container.encode(value)
}
}
}

extension Value: ExpressibleByArrayLiteral {
public init(arrayLiteral elements: Value...) {
self = .array(elements)
}
}

extension Value: ExpressibleByDictionaryLiteral {
public init(dictionaryLiteral elements: (String, Value)...) {
self = .object(Dictionary(uniqueKeysWithValues: elements))
}
}

extension Value: ExpressibleByStringLiteral {
public init(stringLiteral value: String) {
self = .string(value)
}
}

extension Value: ExpressibleByStringInterpolation {}

extension Value: ExpressibleByBooleanLiteral {
public init(booleanLiteral value: Bool) {
self = .bool(value)
}
}

extension Value: ExpressibleByIntegerLiteral {
public init(integerLiteral value: Int) {
self = .int(value)
}
}

extension Value: ExpressibleByFloatLiteral {
public init(floatLiteral value: Double) {
self = .double(value)
}
}

extension Value: ExpressibleByNilLiteral {
public init(nilLiteral: ()) {
self = .null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import NuitBridgeSwiftUICore
import SwiftUI
import XCTest

final class ValueTests: XCTestCase {
func testCoding() throws {
let cases: [(String, Value, UInt)] = [
("true", true, #line),
("false", false, #line),
("42", 42, #line),
("42.5", 42.5, #line),
("[]", [], #line),
("[1,2,3]", [1, 2, 3], #line),
("[1,\"a\",3.2,null]", [1, "a", 3.2, nil], #line),
("{\"b\":[9,null,null]}", ["b": [9, nil, nil]], #line),
]

for (json, value, line) in cases {
XCTAssertEqual(
try JSONDecoder().decode(Value.self, from: json.data(using: .utf8)!),
value,
"Could not decode JSON",
line: line
)

XCTAssertEqual(
String(data: try JSONEncoder().encode(value), encoding: .utf8)!,
json,
"Could not encode to JSON",
line: line
)
}
}
}
Loading

0 comments on commit b29e54a

Please sign in to comment.