Session Page Component - TJohn2017/Laputa GitHub Wiki
The Session Page component is largely composed of the SessionPageView, detailed in SessionPageView.swift. It is responsible for managing a session (terminal(s) component, canvas component), all moving and changing global variables (canvas, hosts, connections, canvas state variables, resizing variables, input sheets), and any state changes (e.g. changing from terminalOnly / CanvasOnly to splitSession). In each section, we'll describe different aspects of the SessionPageView architecture and functionality as well as relevant state variables that are used to enable the different parts of the architecture.
Initialization
A SessionPageView instance is meant to be initialized with only two inputs, at most, namely either with only a canvas entity, only a host entity, or both a canvas entity and a host entity --- these are the startHost and startCanvas parameters in the initialization function. All other state variables are meant to be internal to SessionPageView. This is intended in order to make it easier to initialize a session from MainPage (or any higher-up parent view components).
Given either of these two components, SessionPageView then updates any of the corresponding state variables for later use. If startCanvas is passed in, then the currCanvas optional Canvas variable and canvases array is initialized to the input startCanvas and if startHost is passed in, then we initialize a hosts array to start with startHost and create a new SSHConnection object that we initialize a connections array with. The currCanvas variable represents the current canvas entity that this session is opened to (can be nil if the session hasn't opened a canvas yet). The canvases array is intended to maintain a list of any opened canvases in the event that we add multiple canvas functionality. Similarly, the hosts array keeps track of any opened host entities in the current session while the connections array maintains the corresponding SSHConnection objects for each host in the hosts array. With these four critical state variables, SessionPageView is ready launch and maintain a session.
Navigation Bar
Because a SessionPageView encompasses both any canvas and/or terminal component, it's responsible for setting, updating, and presenting the navigation bar at the top of each session instance. This has all been abstracted into one place, namely, in the body variable of the SessionPageView where we set some basic parameters that don't change as well as set any leading elements (like the back button which is responsible for a lot of the clean-up / destructor work when the user leaves the session or the navigation bar title, if we have one) and any trailing elements (like any of the Canvas pencil kit utilities or the "plus" button which handles adding terminal(s) / canvas to session)
var body: some View {
ZStack {
Color("CanvasMain")
self.sessionInstance
.navigationBarTitle("")
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.navigationBarHidden(self.backButtonPressed)
.navigationBarItems(
leading: self.navigationBarLeadingButtons,
trailing: self.navigationBarTrailingButtons
)
.sheet(item: $activeSheet) { item in
switch item {
// Choosing canvas to add.
case .addCanvas:
AddCanvasView(
canvas: $currCanvas,
selectedCanvases: $canvases,
activeSheet: $activeSheet
)
case .addHost:
AddHostView(
hosts: $hosts,
connections: $connections,
activeSheet: $activeSheet
)
default:
EmptyView()
}
}
}
}
The leading elements are set in the navigationBarLeadingButtons View variable while the trailing elements are set in the navigationBarTrailingButtons View variable. Both variables identify the current state of the session and present different elements / allow certain functionality depending on the state of the session (see Session State Handling for clarification on the different states). The navigationBarLeadingButtons View is responsible for updating the backButtonPressed state variable while the navigationBarTrailingButtons View is responsible for updating the isDraw, isErase, color, and type state variables when a canvas is active.
Multiple Terminals Component (CustomTabView)
In order to support multiple-terminal / terminal-tab-bar functionality, SessionPageView largely depends on a utility View titled CustomTabView that we developed in order to make it easy to dynamically add new terminal connections and present them in a functional tab set-up. CustomTabView largely works by reading in any arbitrary view (via a View Builder) and then wrapping each passed in view with a TabContainer view (via a customTab(name: String, tabNumber: Int) view extension) that then evaluates whether to present or hide the current content depending on what the currently selected tab is --- this is all largely maintained by CustomTabView independently but in order to get it to work, SessionPage still needs to pass in a tabNumber to the customTab extension when initializing in order to ensure that tabs are presented in the correct order and maintained accordingly.
CustomTabView(
tabBarPosition: TabBarPosition.top,
numberOfElems: hosts.count
) {
ForEach((0..<hosts.count), id: \.self) {
SwiftUITerminal(
canvas: $currCanvas,
connections: $connections,
connectionIdx: $0,
modifyTerminalHeight: false,
splitScreenHeight: $splitScreenHeight,
id: $0
)
.customTab(
name: "\(hosts[$0]!.name)",
tabNumber: $0
)
}
}
Because CustomTabView uses a View Builder, it makes it easy to simply create a dynamic for-each loop that iterates over any of the current hosts / connections, initializes a corresponding SwiftUITerminal, and wraps the terminal component for tab-use via a customTab view extension, all without handling any more complicated state changes. Whenever a host is added to the session, the hosts array will update and the view will simply update by re-running the same code: SwiftUITerminal maintains static copies of any previously initialized terminal components but can just as easily create a new one if a new host is added and because of the view extension, the extra component will also be easily wrapped and added to the CustomTabView tab bar set-up. Moreover, this also simplifies the one or multiple terminal case into one simple case --- the CustomTabView handles both states cleanly regardless of whether one or more terminal components are currently active.
All multiple-terminal functionality (i.e. CustomTabView use) in SessionPageView is condensed into the sessionInstance view variable which handles all the different configurations for a session --- see Session State Handling for more.
Connection Handling
Connection handling has also been developed pretty cleanly in order to make it as easy as possible for SessionPageView to update any current connections. Currently, when a host is first added to the hosts state array, a corresponding SSHConnection object must also be initialized and added to the connections state array but when this first occurs, the SSHConnection object is only initialized and still hasn't actively connected to the target host. Instead this is handled alongside all of the dynamic terminal component code that is included as part of the multiple-terminal / terminal-tab-bar functionality and so it lives in the sessionInstance view variable.
In practice, connection handling is made easy through a SessionPageView helper method establishConnection() which simply iterates through the connections array of SSHConnection objects and attempts to connect to the target object if the connection has not already been made. This method is called via a .onAppear and .onChange modifier that is added to the dynamic terminal component code so that when the view loads or the connections array is updated, the method is immediately called and any new connections are immediately made.
[CustomTabView code from above]
.onAppear(perform: establishConnection)
.onChange(
of: self.connections,
perform: { _ in
self.establishConnection()
}
)
When the user decides to a leave the session (i.e. by pressing the back button), SessionPageView is also responsible for iterating through the connections array and calling the helper disconnect method of each SSHConnection object in order to manually disconnect before terminating the session.
Session State Handling
The three key states (and fourth error state) of a session is categorized under the SessionState enum:
enum SessionState: String {
case terminalOnly // A terminal(s)-only session.
case canvasOnly // A canvas-only session.
case splitSession // A terminal(s) and canvas session.
case error
}
These are the key states of a session and while it is a small number, managing multiple views across multiple sessions has posed a difficult challenge. We've managed to simplify the state maintenance code to the sessionInstance view variable in SessionPageView which determines the current state of the session via a call to the helper method getSessionState() (which simply determines the state based on whether a canvas entity is present and the number of present hosts) and then switches to the corresponding state code.
var sessionInstance: some View {
let sessionState = self.getSessionState()
switch sessionState {
case .terminalOnly:
return [terminalOnly code]
case .canvasOnly:
return [canvasOnly code]
case .splitSession:
return [splitSession code]
default:
return [error code]
}
}
In the terminal-only case, the only component that we need to return is the CustomTabView component from the multiple terminal functionality section above (+ the connection modifier code). In the canvas-only case, we only have to return a CanvasView albeit wrapped in a GeometryReader View. Finally, in the split-session case, we combine both the customTabView component and the CanvasView component together but also add in extra modifiers to dynamically change the relative size of each view when the user performs any resizing
Resizing split view
In order to resize the split view, we use a SwiftUI dragGesture on a small rectangle handle overlaid between the terminal and canvas views.
Initially, we wanted the views to dynamically resize as the user dragged the handle, but having the terminal view update constantly led to memory problems. As a result, we only show the line where the split will end up, and only when the gesture is ended do we actually update the sizes of the two views. This is similar to our solution for the resizable code cards, which only update after the resize gesture is completed.