@@ -12,19 +12,27 @@ struct ContentView: View {
1212 // QR text + image
1313 @State private var qrInputtext = " "
1414 @State private var image : NSImage ?
15+ private let maxQRBytes = 1273 // QR v40-H byte capacity (byte mode)
16+ @State private var limitWarning : String ? = nil
17+ @State private var previousValidText : String = " "
1518
16- // File management
19+ // File management (app-managed root in Application Support)
1720 @State private var rootFolderURL : URL ?
1821 @State private var tree : FileNode ?
1922 @State private var selectedFileURL : URL ?
2023 @State private var statusMessage : String = " "
24+ // Name sheet (for Save As / Rename without NSSavePanel)
25+ @State private var showNameSheet : Bool = false
26+ @State private var nameSheetTitle : String = " "
27+ @State private var nameField : String = " "
28+ private enum NameAction { case saveAs, rename }
29+ @State private var pendingAction : NameAction ? = nil
2130
2231 var body : some View {
2332 HStack ( spacing: 0 ) {
2433 // Sidebar: folder tree and controls
2534 VStack ( alignment: . leading, spacing: 8 ) {
2635 HStack {
27- Button ( " Choose Root Folder " ) { chooseRootFolder ( ) }
2836 Button ( " Refresh " ) { refreshTree ( ) }
2937 . disabled ( rootFolderURL == nil )
3038 }
@@ -46,16 +54,16 @@ struct ContentView: View {
4654 selectedFileURL = node. url
4755 if let txt = FileScanner . readText ( from: node. url) {
4856 qrInputtext = txt
49- image = QRCodeGenerator . getQRImageUsingNew ( qrcode : qrInputtext )
57+ enforceLimitAndUpdate ( )
5058 }
5159 }
5260 }
5361 }
5462 }
5563 } else {
5664 VStack ( alignment: . leading, spacing: 8 ) {
57- Text ( " No root folder selected. " )
58- Text ( " Click ‘Choose Root Folder’ to browse and manage QR texts ." )
65+ Text ( " Preparing app library… " )
66+ Text ( " Your QR files are saved in the app’s Application Support folder ." )
5967 . foregroundColor ( . secondary)
6068 }
6169 . padding ( )
@@ -77,8 +85,13 @@ struct ContentView: View {
7785 . frame ( minHeight: 30 , maxHeight: 80 )
7886 . overlay ( RoundedRectangle ( cornerRadius: 6 ) . stroke ( Color . secondary. opacity ( 0.2 ) ) )
7987 . onChange ( of: qrInputtext) { _ in
80- image = QRCodeGenerator . getQRImageUsingNew ( qrcode : qrInputtext )
88+ enforceLimitAndUpdate ( )
8189 }
90+ if let warning = limitWarning {
91+ Text ( warning)
92+ . font ( . caption)
93+ . foregroundColor ( . red)
94+ }
8295 }
8396 . padding ( [ . top, . horizontal, . bottom] )
8497
@@ -102,48 +115,45 @@ struct ContentView: View {
102115 }
103116 }
104117 . onAppear {
118+ // Ensure app root exists and load tree
119+ self . rootFolderURL = ensureAppRoot ( )
120+ refreshTree ( )
105121 // Initial render if any prefilled text
106122 image = QRCodeGenerator . getQRImageUsingNew ( qrcode: qrInputtext)
123+ previousValidText = qrInputtext
124+ }
125+ . sheet ( isPresented: $showNameSheet) {
126+ VStack ( alignment: . leading, spacing: 12 ) {
127+ Text ( nameSheetTitle) . font ( . headline)
128+ TextField ( " Filename " , text: $nameField)
129+ . textFieldStyle ( RoundedBorderTextFieldStyle ( ) )
130+ . frame ( minWidth: 360 )
131+ Text ( " Allowed extensions: \( Array ( FileScanner . allowedExtensions) . sorted ( ) . joined ( separator: " , " ) ) " )
132+ . font ( . caption)
133+ . foregroundColor ( . secondary)
134+ HStack {
135+ Spacer ( )
136+ Button ( " Cancel " ) { showNameSheet = false }
137+ Button ( " Save " ) { performNameAction ( ) } . keyboardShortcut ( . defaultAction)
138+ }
139+ }
140+ . padding ( 20 )
107141 }
108142 }
109143
110144 // MARK: - Actions
111145
112- private func chooseRootFolder( ) {
113- let panel = NSOpenPanel ( )
114- panel. canChooseDirectories = true
115- panel. canChooseFiles = false
116- panel. allowsMultipleSelection = false
117- panel. prompt = " Choose "
118- if panel. runModal ( ) == . OK, let url = panel. url {
119- rootFolderURL = url
120- refreshTree ( )
121- status ( " Root set to \( url. lastPathComponent) " )
122- }
123- }
124-
125146 private func refreshTree( ) {
126147 guard let root = rootFolderURL else { return }
127148 tree = FileScanner . buildTree ( at: root)
128149 }
129150
130151 private func saveAs( ) {
131- guard let root = rootFolderURL else { return }
132- // Ask for filename
133- let savePanel = NSSavePanel ( )
134- savePanel. directoryURL = root
135- savePanel. allowedFileTypes = [ " qr " , " txt " , " qrtext " ]
136- savePanel. nameFieldStringValue = suggestFileName ( )
137- if savePanel. runModal ( ) == . OK, let url = savePanel. url {
138- do {
139- try FileScanner . writeText ( qrInputtext, to: url)
140- selectedFileURL = url
141- refreshTree ( )
142- status ( " Saved \( url. lastPathComponent) " )
143- } catch {
144- status ( " Save failed: \( error. localizedDescription) " )
145- }
146- }
152+ // Prompt for filename within app library
153+ nameSheetTitle = " Save As "
154+ nameField = suggestFileName ( ) + " .txt "
155+ pendingAction = . saveAs
156+ showNameSheet = true
147157 }
148158
149159 private func saveCurrent( ) {
@@ -169,22 +179,10 @@ struct ContentView: View {
169179
170180 private func renameCurrent( ) {
171181 guard let currentURL = selectedFileURL else { return }
172- let panel = NSSavePanel ( )
173- panel. directoryURL = currentURL. deletingLastPathComponent ( )
174- panel. nameFieldStringValue = currentURL. lastPathComponent
175- panel. allowedFileTypes = Array ( FileScanner . allowedExtensions)
176- panel. prompt = " Rename "
177- if panel. runModal ( ) == . OK, let newURL = panel. url {
178- guard newURL != currentURL else { return }
179- do {
180- try FileManager . default. moveItem ( at: currentURL, to: newURL)
181- selectedFileURL = newURL
182- refreshTree ( )
183- status ( " Renamed to \( newURL. lastPathComponent) " )
184- } catch {
185- status ( " Rename failed: \( error. localizedDescription) " )
186- }
187- }
182+ nameSheetTitle = " Rename "
183+ nameField = currentURL. lastPathComponent
184+ pendingAction = . rename
185+ showNameSheet = true
188186 }
189187
190188 private func status( _ message: String ) {
@@ -202,6 +200,114 @@ struct ContentView: View {
202200 formatter. dateFormat = " yyyy-MM-dd_HH-mm-ss "
203201 return formatter. string ( from: Date ( ) )
204202 }
203+
204+ // MARK: - App folder helpers
205+
206+ private func ensureAppRoot( ) -> URL ? {
207+ let fm = FileManager . default
208+ let base = ( try ? fm. url ( for: . applicationSupportDirectory, in: . userDomainMask, appropriateFor: nil , create: true ) )
209+ let bundleID = Bundle . main. bundleIdentifier ?? " TextToQR "
210+ guard let baseURL = base else { return nil }
211+ let appRoot = baseURL. appendingPathComponent ( bundleID, isDirectory: true )
212+ . appendingPathComponent ( " QRCodes " , isDirectory: true )
213+ do {
214+ try fm. createDirectory ( at: appRoot, withIntermediateDirectories: true )
215+ return appRoot
216+ } catch {
217+ status ( " Could not create app folder: \( error. localizedDescription) " )
218+ return nil
219+ }
220+ }
221+
222+ private func forceIntoRoot( _ url: URL , root: URL ) -> URL {
223+ // If user navigates outside the app root in the save panel, force saving under root with chosen filename
224+ let standardized = url. standardizedFileURL
225+ if standardized. path. hasPrefix ( root. standardizedFileURL. path) {
226+ return standardized
227+ }
228+ return root. appendingPathComponent ( url. lastPathComponent)
229+ }
230+
231+ // MARK: - Input limit enforcement
232+
233+ private func asciiByteCount( _ text: String ) -> Int ? {
234+ text. data ( using: . ascii) ? . count
235+ }
236+
237+ private func truncateToMaxASCIIBytes( _ text: String , max: Int ) -> String {
238+ var result = " "
239+ var count = 0
240+ for ch in text {
241+ let s = String ( ch)
242+ guard let bytes = s. data ( using: . ascii) else { break }
243+ if count + bytes. count > max { break }
244+ result. append ( ch)
245+ count += bytes. count
246+ }
247+ return result
248+ }
249+
250+ private func enforceLimitAndUpdate( ) {
251+ // Reject non-ASCII and enforce max byte length for current QR encoding settings
252+ guard let byteCount = asciiByteCount ( qrInputtext) else {
253+ limitWarning = " Only ASCII characters are supported. "
254+ // revert to last valid
255+ qrInputtext = previousValidText
256+ return
257+ }
258+ if byteCount > maxQRBytes {
259+ // Truncate and warn
260+ let truncated = truncateToMaxASCIIBytes ( qrInputtext, max: maxQRBytes)
261+ qrInputtext = truncated
262+ limitWarning = " Exceeded max length of \( maxQRBytes) bytes. Extra text truncated. "
263+ } else {
264+ limitWarning = nil
265+ previousValidText = qrInputtext
266+ }
267+ image = QRCodeGenerator . getQRImageUsingNew ( qrcode: qrInputtext)
268+ }
269+
270+ private func sanitizeFilename( _ name: String ) -> String {
271+ var base = name. trimmingCharacters ( in: . whitespacesAndNewlines)
272+ if base. isEmpty { base = suggestFileName ( ) }
273+ // Replace illegal chars
274+ base = base. replacingOccurrences ( of: " \\ s+ " , with: " _ " , options: . regularExpression)
275+ . replacingOccurrences ( of: " [ \\ \\ /:*? \" <>|] " , with: " - " , options: . regularExpression)
276+ return base
277+ }
278+
279+ private func ensureAllowedExtension( for filename: String ) -> String {
280+ let url = URL ( fileURLWithPath: filename)
281+ let ext = url. pathExtension. lowercased ( )
282+ if FileScanner . allowedExtensions. contains ( ext) { return filename }
283+ // default to .txt
284+ return url. deletingPathExtension ( ) . lastPathComponent + " .txt "
285+ }
286+
287+ private func performNameAction( ) {
288+ guard let root = rootFolderURL, let action = pendingAction else { showNameSheet = false ; return }
289+ let cleaned = ensureAllowedExtension ( for: sanitizeFilename ( nameField) )
290+ let dest = root. appendingPathComponent ( cleaned)
291+ do {
292+ switch action {
293+ case . saveAs:
294+ try FileScanner . writeText ( qrInputtext, to: dest)
295+ selectedFileURL = dest
296+ status ( " Saved \( dest. lastPathComponent) " )
297+ case . rename:
298+ if let current = selectedFileURL, current != dest {
299+ try FileManager . default. moveItem ( at: current, to: dest)
300+ selectedFileURL = dest
301+ status ( " Renamed to \( dest. lastPathComponent) " )
302+ }
303+ }
304+ refreshTree ( )
305+ } catch {
306+ status ( " Operation failed: \( error. localizedDescription) " )
307+ }
308+ showNameSheet = false
309+ pendingAction = nil
310+ }
205311}
206312
207313struct ContentView_Previews : PreviewProvider {
0 commit comments