@@ -81,12 +81,7 @@ pub async fn run(figma_arg: Option<String>, flavor: String) -> Result<()> {
8181 match client. get_file ( & file_key) . await {
8282 Ok ( file) => {
8383 println ! ( "{}" , format!( "\" {}\" " , file. name) . green( ) ) ;
84-
85- // Show pages
86- for page in & file. document . children {
87- let frame_count = page. children . len ( ) ;
88- println ! ( " {} {} ({} frames)" , "→" . dimmed( ) , page. name, frame_count) ;
89- }
84+ print_page_tree ( & file. document . children ) ;
9085 }
9186 Err ( e) => {
9287 println ! ( "{}" , format!( "Failed: {e}" ) . red( ) ) ;
@@ -180,6 +175,128 @@ fn check_claude_plugin() {
180175 }
181176}
182177
178+ // ── Page-tree pretty printer ────────────────────────────────────────────
179+ //
180+ // Figma "pages" are a flat list, but designers fake hierarchy with naming
181+ // conventions: ALL-CAPS / "✨"-prefixed names with 0 frames are section
182+ // headers; "↳"-prefixed names are children; runs of dashes/dots are
183+ // dividers. We detect those, throw away the dividers, and render a real
184+ // tree with proper └── / ├── / │ branch glyphs.
185+
186+ use crate :: figma:: types:: CanvasNode ;
187+
188+ enum Item {
189+ Section {
190+ label : String ,
191+ leaves : Vec < ( String , usize ) > ,
192+ } ,
193+ Leaf {
194+ name : String ,
195+ count : usize ,
196+ } ,
197+ }
198+
199+ fn print_page_tree ( canvas_pages : & [ CanvasNode ] ) {
200+ let items = build_items ( canvas_pages) ;
201+
202+ let total_frames: usize = canvas_pages. iter ( ) . map ( |p| p. children . len ( ) ) . sum ( ) ;
203+ let total_pages: usize = canvas_pages
204+ . iter ( )
205+ . filter ( |p| !p. children . is_empty ( ) )
206+ . count ( ) ;
207+
208+ let last = items. len ( ) . saturating_sub ( 1 ) ;
209+ for ( i, item) in items. iter ( ) . enumerate ( ) {
210+ let is_last = i == last;
211+ let branch = if is_last { "└──" } else { "├──" } ;
212+ let trunk = if is_last { " " } else { "│ " } ;
213+
214+ match item {
215+ Item :: Section { label, leaves } => {
216+ println ! ( " {} {}" , branch. dimmed( ) , label. bold( ) ) ;
217+ let llast = leaves. len ( ) . saturating_sub ( 1 ) ;
218+ for ( li, ( name, count) ) in leaves. iter ( ) . enumerate ( ) {
219+ let lbranch = if li == llast {
220+ "└──"
221+ } else {
222+ "├──"
223+ } ;
224+ println ! (
225+ " {}{} {} {}" ,
226+ trunk. dimmed( ) ,
227+ lbranch. dimmed( ) ,
228+ name,
229+ format!( "({count} frames)" ) . dimmed( )
230+ ) ;
231+ }
232+ }
233+ Item :: Leaf { name, count } => {
234+ println ! (
235+ " {} {} {}" ,
236+ branch. dimmed( ) ,
237+ name,
238+ format!( "({count} frames)" ) . dimmed( )
239+ ) ;
240+ }
241+ }
242+ }
243+
244+ println ! ( ) ;
245+ println ! (
246+ " {}" ,
247+ format!( "{total_pages} pages, {total_frames} frames" ) . dimmed( )
248+ ) ;
249+ }
250+
251+ fn build_items ( canvas_pages : & [ CanvasNode ] ) -> Vec < Item > {
252+ let mut items: Vec < Item > = Vec :: new ( ) ;
253+ let mut current_section: Option < ( String , Vec < ( String , usize ) > ) > = None ;
254+
255+ for page in canvas_pages {
256+ let raw = page. name . trim ( ) ;
257+ let frame_count = page. children . len ( ) ;
258+
259+ if frame_count == 0 {
260+ if is_divider ( raw) {
261+ continue ;
262+ }
263+ // New section header — flush the previous one.
264+ if let Some ( ( label, leaves) ) = current_section. take ( ) {
265+ items. push ( Item :: Section { label, leaves } ) ;
266+ }
267+ current_section = Some ( ( strip_page_decoration ( raw) , Vec :: new ( ) ) ) ;
268+ } else {
269+ let leaf = ( strip_page_decoration ( raw) , frame_count) ;
270+ match current_section. as_mut ( ) {
271+ Some ( ( _, leaves) ) => leaves. push ( leaf) ,
272+ None => items. push ( Item :: Leaf {
273+ name : leaf. 0 ,
274+ count : leaf. 1 ,
275+ } ) ,
276+ }
277+ }
278+ }
279+ if let Some ( ( label, leaves) ) = current_section {
280+ items. push ( Item :: Section { label, leaves } ) ;
281+ }
282+ items
283+ }
284+
285+ /// True when the page name is purely visual punctuation (e.g. "----",
286+ /// "- - - - -", ".....") — Figma users insert these as dividers.
287+ fn is_divider ( name : & str ) -> bool {
288+ name. chars ( ) . filter ( |c| c. is_alphabetic ( ) ) . count ( ) < 3
289+ }
290+
291+ /// Strip the leading hierarchy markers ("↳", "✨") and surrounding
292+ /// whitespace, then collapse internal whitespace runs.
293+ fn strip_page_decoration ( name : & str ) -> String {
294+ let trimmed = name. trim_start_matches ( |c : char | {
295+ c. is_whitespace ( ) || c == '↳' || c == '✨' || c == '→' || c == '*'
296+ } ) ;
297+ trimmed. split_whitespace ( ) . collect :: < Vec < _ > > ( ) . join ( " " )
298+ }
299+
183300/// Extract a Figma file key from a URL or raw key string.
184301/// Handles:
185302/// - "abc123DEFghiJKL" (raw key)
@@ -232,4 +349,31 @@ mod tests {
232349 "abc123DEFghiJKL"
233350 ) ;
234351 }
352+
353+ #[ test]
354+ fn test_is_divider ( ) {
355+ assert ! ( is_divider( "----" ) ) ;
356+ assert ! ( is_divider( "- - - - -" ) ) ;
357+ assert ! ( is_divider( "...." ) ) ;
358+ assert ! ( is_divider( "--" ) ) ;
359+ assert ! ( !is_divider( "BRAND SUPPORT" ) ) ;
360+ assert ! ( !is_divider( "✨ LinkedIn Posts" ) ) ;
361+ }
362+
363+ #[ test]
364+ fn test_strip_page_decoration ( ) {
365+ assert_eq ! (
366+ strip_page_decoration( "↳ Town Hall Designs" ) ,
367+ "Town Hall Designs"
368+ ) ;
369+ assert_eq ! (
370+ strip_page_decoration( "✨ Brand Support – Marketing Materials" ) ,
371+ "Brand Support – Marketing Materials"
372+ ) ;
373+ assert_eq ! ( strip_page_decoration( "BRAND SUPPORT" ) , "BRAND SUPPORT" ) ;
374+ // Emoji that aren't markers stay put.
375+ assert_eq ! ( strip_page_decoration( "📌 Thumbnail" ) , "📌 Thumbnail" ) ;
376+ // Internal whitespace collapses.
377+ assert_eq ! ( strip_page_decoration( "↳ AHIMA 2025" ) , "AHIMA 2025" ) ;
378+ }
235379}
0 commit comments