1+ package net .kdt .pojavlaunch .modloaders .modpacks ;
2+
3+ import android .view .LayoutInflater ;
4+ import android .view .View ;
5+ import android .view .ViewGroup ;
6+ import android .widget .CheckBox ;
7+ import android .widget .ImageView ;
8+ import android .widget .TextView ;
9+
10+ import androidx .annotation .NonNull ;
11+ import androidx .recyclerview .widget .RecyclerView ;
12+
13+ import net .kdt .pojavlaunch .R ;
14+
15+ import java .io .File ;
16+ import java .util .ArrayList ;
17+ import java .util .Collections ;
18+ import java .util .Comparator ;
19+ import java .util .Iterator ;
20+ import java .util .List ;
21+ import java .util .Map ;
22+
23+ /**
24+ * Renders an expandable, checkable file/folder tree for the .mrpack export dialog, the same
25+ * way the official Modrinth app lets you drill into a folder and deselect individual files
26+ * instead of just toggling the whole folder.
27+ * <p/>
28+ * Selection state lives in a single {@code Map<String, Boolean>} (path relative to the
29+ * instance root -> explicit checked state) that is shared with {@code MrpackExporter}: a path
30+ * with no explicit entry simply inherits the state of its nearest ancestor that has one. This
31+ * means the export step doesn't need this adapter at all — it can recompute the same effective
32+ * state by walking the same map.
33+ */
34+ public class ExportFileTreeAdapter extends RecyclerView .Adapter <ExportFileTreeAdapter .RowHolder > {
35+
36+ private static class Node {
37+ final File file ;
38+ final String relPath ;
39+ final boolean isDirectory ;
40+ final int depth ;
41+ boolean expanded = false ;
42+ List <Node > children ;
43+
44+ Node (File file , String relPath , int depth ) {
45+ this .file = file ;
46+ this .relPath = relPath ;
47+ this .isDirectory = file .isDirectory ();
48+ this .depth = depth ;
49+ }
50+ }
51+
52+ private final List <Node > mVisibleNodes = new ArrayList <>();
53+ private final Map <String , Boolean > mOverrides ;
54+ private final int mIndentPx ;
55+
56+ public ExportFileTreeAdapter (File instanceDir , Map <String , Boolean > overrides , int indentPx ) {
57+ mOverrides = overrides ;
58+ mIndentPx = indentPx ;
59+ mVisibleNodes .addAll (buildNodeList (instanceDir , "" , 0 ));
60+ }
61+
62+ private static List <Node > buildNodeList (File parent , String parentRelPath , int depth ) {
63+ File [] files = parent .listFiles ();
64+ List <Node > nodes = new ArrayList <>();
65+ if (files == null ) return nodes ;
66+
67+ List <File > directories = new ArrayList <>();
68+ List <File > plainFiles = new ArrayList <>();
69+ for (File f : files ) {
70+ if (f .isDirectory ()) directories .add (f );
71+ else plainFiles .add (f );
72+ }
73+ Comparator <File > byNameIgnoreCase = (a , b ) -> a .getName ().compareToIgnoreCase (b .getName ());
74+ Collections .sort (directories , byNameIgnoreCase );
75+ Collections .sort (plainFiles , byNameIgnoreCase );
76+
77+ List <File > ordered = new ArrayList <>(directories .size () + plainFiles .size ());
78+ ordered .addAll (directories );
79+ ordered .addAll (plainFiles );
80+
81+ for (File f : ordered ) {
82+ String relPath = parentRelPath .isEmpty () ? f .getName () : parentRelPath + "/" + f .getName ();
83+ nodes .add (new Node (f , relPath , depth ));
84+ }
85+ return nodes ;
86+ }
87+
88+ private boolean effectiveChecked (String relPath ) {
89+ String path = relPath ;
90+ while (true ) {
91+ Boolean explicit = mOverrides .get (path );
92+ if (explicit != null ) return explicit ;
93+ int lastSlash = path .lastIndexOf ('/' );
94+ if (lastSlash < 0 ) return false ;
95+ path = path .substring (0 , lastSlash );
96+ }
97+ }
98+
99+ private void setCheckedCascading (Node node , boolean checked ) {
100+ mOverrides .put (node .relPath , checked );
101+ // Drop any explicit overrides belonging to already-known descendants so they go back
102+ // to inheriting from this node, instead of conflicting with the new state.
103+ String prefix = node .relPath + "/" ;
104+ Iterator <String > keys = mOverrides .keySet ().iterator ();
105+ while (keys .hasNext ()) {
106+ if (keys .next ().startsWith (prefix )) keys .remove ();
107+ }
108+ if (node .children != null ) {
109+ for (Node child : node .children ) setCheckedCascading (child , checked );
110+ }
111+ }
112+
113+ @ NonNull
114+ @ Override
115+ public RowHolder onCreateViewHolder (@ NonNull ViewGroup parent , int viewType ) {
116+ View view = LayoutInflater .from (parent .getContext ())
117+ .inflate (R .layout .item_export_tree_entry , parent , false );
118+ return new RowHolder (view );
119+ }
120+
121+ @ Override
122+ public void onBindViewHolder (@ NonNull RowHolder holder , int position ) {
123+ holder .bind (mVisibleNodes .get (position ));
124+ }
125+
126+ @ Override
127+ public int getItemCount () {
128+ return mVisibleNodes .size ();
129+ }
130+
131+ private void toggleExpand (Node node ) {
132+ int index = mVisibleNodes .indexOf (node );
133+ if (index < 0 ) return ;
134+
135+ if (node .expanded ) {
136+ int removeCount = countVisibleDescendants (index , node .depth );
137+ for (int i = 0 ; i < removeCount ; i ++) mVisibleNodes .remove (index + 1 );
138+ node .expanded = false ;
139+ notifyItemChanged (index );
140+ if (removeCount > 0 ) notifyItemRangeRemoved (index + 1 , removeCount );
141+ } else {
142+ if (node .children == null ) node .children = buildNodeList (node .file , node .relPath , node .depth + 1 );
143+ mVisibleNodes .addAll (index + 1 , node .children );
144+ node .expanded = true ;
145+ notifyItemChanged (index );
146+ if (!node .children .isEmpty ()) notifyItemRangeInserted (index + 1 , node .children .size ());
147+ }
148+ }
149+
150+ private int countVisibleDescendants (int index , int depth ) {
151+ int count = 0 ;
152+ for (int i = index + 1 ; i < mVisibleNodes .size (); i ++) {
153+ if (mVisibleNodes .get (i ).depth <= depth ) break ;
154+ count ++;
155+ }
156+ return count ;
157+ }
158+
159+ private void refreshVisibleDescendants (Node node ) {
160+ int index = mVisibleNodes .indexOf (node );
161+ if (index < 0 ) return ;
162+ int count = countVisibleDescendants (index , node .depth );
163+ if (count > 0 ) notifyItemRangeChanged (index + 1 , count );
164+ }
165+
166+ class RowHolder extends RecyclerView .ViewHolder {
167+ final View indentSpacer ;
168+ final CheckBox checkBox ;
169+ final TextView nameView ;
170+ final ImageView chevron ;
171+
172+ RowHolder (@ NonNull View itemView ) {
173+ super (itemView );
174+ indentSpacer = itemView .findViewById (R .id .export_entry_indent );
175+ checkBox = itemView .findViewById (R .id .export_entry_checkbox );
176+ nameView = itemView .findViewById (R .id .export_entry_name );
177+ chevron = itemView .findViewById (R .id .export_entry_chevron );
178+ }
179+
180+ void bind (Node node ) {
181+ ViewGroup .LayoutParams lp = indentSpacer .getLayoutParams ();
182+ lp .width = node .depth * mIndentPx ;
183+ indentSpacer .setLayoutParams (lp );
184+
185+ nameView .setText (node .isDirectory ? node .file .getName () + "/" : node .file .getName ());
186+
187+ checkBox .setOnCheckedChangeListener (null );
188+ checkBox .setChecked (effectiveChecked (node .relPath ));
189+ checkBox .setOnCheckedChangeListener ((buttonView , isChecked ) -> {
190+ setCheckedCascading (node , isChecked );
191+ refreshVisibleDescendants (node );
192+ });
193+
194+ if (node .isDirectory ) {
195+ chevron .setVisibility (View .VISIBLE );
196+ chevron .setRotation (node .expanded ? 0f : 180f );
197+ View .OnClickListener expandListener = v -> toggleExpand (node );
198+ chevron .setOnClickListener (expandListener );
199+ itemView .setOnClickListener (expandListener );
200+ } else {
201+ chevron .setVisibility (View .INVISIBLE );
202+ chevron .setOnClickListener (null );
203+ itemView .setOnClickListener (null );
204+ }
205+ }
206+ }
207+ }
0 commit comments